Internationalize UI (EN default) + 5-language switcher

English is now the default language. Added src/i18n.ts (t() helper +
dictionary) covering all UI strings across App, Updater and ModelViewer,
plus a language selector in Settings for English, Русский, Deutsch,
Français, Español (persisted in localStorage). Backend error strings
translated to English. Also removed the purple highlight on the resize
handles (cursor-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 14:10:35 +03:00
parent fdcc9efb31
commit 46a3be4489
6 changed files with 402 additions and 156 deletions

View File

@ -46,7 +46,7 @@ fn run_json(conn: &Conn, args: &[&str]) -> Result<Vec<Value>, String> {
let out = p4_cmd(conn, true)
.args(args)
.output()
.map_err(|e| format!("Не удалось запустить p4: {e}"))?;
.map_err(|e| format!("Failed to start p4: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout);
let mut items = Vec::new();
@ -134,7 +134,7 @@ async fn p4_login(
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Не удалось запустить p4: {e}"))?;
.map_err(|e| format!("Failed to start p4: {e}"))?;
if let Some(si) = child.stdin.as_mut() {
si.write_all(format!("{password}\n").as_bytes())
@ -149,7 +149,7 @@ async fn p4_login(
err.trim().to_string()
};
return Err(if err.is_empty() {
"Ошибка входа (неверный логин или пароль)".into()
"Login failed (wrong user or password)".into()
} else {
err
});
@ -181,7 +181,7 @@ async fn p4_restore(
fn current(state: &State<AppState>) -> Result<Conn, String> {
let c = state.0.lock().unwrap().clone();
if c.port.is_empty() {
return Err("Нет активного подключения".into());
return Err("No active connection".into());
}
Ok(c)
}
@ -282,7 +282,7 @@ fn run_text(conn: &Conn, args: &[&str]) -> Result<String, String> {
let out = p4_cmd(conn, false)
.args(args)
.output()
.map_err(|e| format!("Не удалось запустить p4: {e}"))?;
.map_err(|e| format!("Failed to start p4: {e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if !out.status.success() && !stderr.is_empty() {
@ -296,7 +296,7 @@ fn run_text(conn: &Conn, args: &[&str]) -> Result<String, String> {
async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result<String, String> {
let conn = current(&state)?;
let msg = run_text(&conn, &["sync", &scope_spec(&scope)])?;
Ok(if msg.is_empty() { "Уже актуально — обновлять нечего.".into() } else { msg })
Ok(if msg.is_empty() { "Already up to date — nothing to sync.".into() } else { msg })
}
/// Unified diff of an opened file vs. the have revision.
@ -329,7 +329,7 @@ fn create_changelist(conn: &Conn, desc: &str) -> Result<String, String> {
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Не удалось запустить p4: {e}"))?;
.map_err(|e| format!("Failed to start p4: {e}"))?;
if let Some(si) = child.stdin.as_mut() {
si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?;
}
@ -342,7 +342,7 @@ fn create_changelist(conn: &Conn, desc: &str) -> Result<String, String> {
s.split_whitespace()
.find(|w| w.chars().all(|c| c.is_ascii_digit()) && !w.is_empty())
.map(|n| n.to_string())
.ok_or_else(|| format!("Не удалось создать changelist: {}", s.trim()))
.ok_or_else(|| format!("Failed to create changelist: {}", s.trim()))
}
/// "Commit" (local): create a pending numbered changelist with a description and
@ -356,7 +356,7 @@ async fn p4_commit(
) -> Result<String, String> {
let conn = current(&state)?;
if files.is_empty() {
return Err("Нет файлов для коммита".into());
return Err("No files to commit".into());
}
let cl = create_changelist(&conn, &description)?;
let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl];
@ -383,7 +383,7 @@ async fn p4_submit(
) -> Result<String, String> {
let conn = current(&state)?;
if files.is_empty() {
return Err("Не выбрано ни одного файла для submit".into());
return Err("No files selected to submit".into());
}
let total = run_json(&conn, &["opened"])?.len();
// all opened files selected → submit the whole default changelist in one go
@ -478,7 +478,7 @@ async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result<
.into_iter()
.next()
.and_then(|v| v.get("path").and_then(|x| x.as_str()).map(String::from))
.ok_or_else(|| "Не удалось определить локальный путь".to_string())?;
.ok_or_else(|| "Could not resolve the local path".to_string())?;
// strip the trailing "..." wildcard and separators → the folder itself
p.trim_end_matches(['.', '/', '\\']).to_string()
} else {
@ -486,7 +486,7 @@ async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result<
};
let path = std::path::Path::new(&local);
if !path.exists() {
return Err(format!("Путь ещё не синхронизирован на диск: {local}"));
return Err(format!("Path not synced to disk yet: {local}"));
}
let mut c = Command::new("explorer");
#[cfg(windows)]
@ -500,7 +500,7 @@ async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result<
} else {
c.arg(&local);
}
c.spawn().map_err(|e| format!("Не удалось открыть проводник: {e}"))?;
c.spawn().map_err(|e| format!("Could not open Explorer: {e}"))?;
Ok(())
}
@ -517,7 +517,7 @@ async fn p4_describe(state: State<'_, AppState>, change: String) -> Result<Value
async fn read_file(path: String) -> Result<tauri::ipc::Response, String> {
std::fs::read(&path)
.map(tauri::ipc::Response::new)
.map_err(|e| format!("Не удалось прочитать файл: {e}"))
.map_err(|e| format!("Could not read file: {e}"))
}
/// Read the local working-copy bytes of a depot file (resolves via `p4 where`).
@ -528,10 +528,10 @@ async fn read_depot(state: State<'_, AppState>, depot: String) -> Result<tauri::
.into_iter()
.next()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
.ok_or_else(|| "Не удалось определить путь файла".to_string())?;
.ok_or_else(|| "Could not resolve the file path".to_string())?;
std::fs::read(&local)
.map(tauri::ipc::Response::new)
.map_err(|e| format!("Не удалось прочитать файл: {e}"))
.map_err(|e| format!("Could not read file: {e}"))
}
/// Switch the active workspace (client) without re-authenticating.
@ -540,7 +540,7 @@ async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result<
let conn = {
let mut c = state.0.lock().unwrap();
if c.port.is_empty() {
return Err("Нет активного подключения".into());
return Err("No active connection".into());
}
c.client = client;
c.clone()

View File

@ -82,9 +82,7 @@ button{font-family:var(--font)}
.tzone:last-child{border-right:none}.tzone:hover{background:rgba(124,110,246,.06)}
/* draggable panel dividers */
.vhandle{position:absolute;top:0;bottom:0;width:9px;cursor:col-resize;z-index:8;transition:background .12s}
.vhandle:hover{background:rgba(124,110,246,.3)}
.body-resizing-x .vhandle,body.resizing .vhandle{background:rgba(124,110,246,.45)}
.vhandle{position:absolute;top:0;bottom:0;width:9px;cursor:col-resize;z-index:8}
.main{position:relative}
.main > .vhandle.mainh{left:var(--left-w,380px);transform:translateX(-4px)}
.tgroup > .vhandle.grouph{right:var(--right-zone-w,220px);transform:translateX(4px)}
@ -230,6 +228,15 @@ body.resizing{cursor:col-resize!important;user-select:none}
.upd-go{flex:1;padding:8px 14px;border:none;border-radius:9px;font-size:12.5px;font-weight:600;color:#fff;cursor:pointer;
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 20px -8px rgba(124,110,246,.7)}
.upd-go:hover{filter:brightness(1.08)}
/* language selector (Settings) */
.langsel{display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end;flex:1}
.langbtn{display:flex;align-items:center;gap:6px;padding:5px 10px;border-radius:9px;border:1px solid var(--border);
background:var(--panel);color:var(--muted);font-size:12px;cursor:pointer;font-family:var(--font);transition:.14s}
.langbtn:hover{color:var(--txt);border-color:var(--accent)}
.langbtn.on{color:#fff;border-color:transparent;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 6px 16px -6px rgba(124,110,246,.7)}
.langbtn .lflag{font-size:14px;line-height:1}
.langbtn .lname{font-weight:500}
.tzone.push .ic{color:var(--add)}
.tzone.push .val{color:var(--add)}
.commit .row{display:flex;gap:10px;align-items:center}

View File

@ -6,15 +6,16 @@ import ModelViewer from "./ModelViewer";
import UpdateBanner from "./Updater";
import "./App.css";
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
/* ---------------- window controls (frameless) ---------------- */
function WinControls() {
const w = getCurrentWindow();
return (
<div className="wctrls">
<button className="wbtn" onClick={() => w.minimize()} title="Свернуть"><svg viewBox="0 0 12 12"><rect x="2" y="5.6" width="8" height="1" fill="currentColor" /></svg></button>
<button className="wbtn" onClick={() => w.toggleMaximize()} title="Развернуть"><svg viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" stroke="currentColor" strokeWidth="1" /></svg></button>
<button className="wbtn close" onClick={() => w.close()} title="Закрыть"><svg viewBox="0 0 12 12" fill="none"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg></button>
<button className="wbtn" onClick={() => w.minimize()} title={t("Minimize")}><svg viewBox="0 0 12 12"><rect x="2" y="5.6" width="8" height="1" fill="currentColor" /></svg></button>
<button className="wbtn" onClick={() => w.toggleMaximize()} title={t("Maximize")}><svg viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" stroke="currentColor" strokeWidth="1" /></svg></button>
<button className="wbtn close" onClick={() => w.close()} title={t("Close")}><svg viewBox="0 0 12 12" fill="none"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg></button>
</div>
);
}
@ -49,12 +50,19 @@ function useTheme(): [boolean, () => void] {
return [light, () => setLight((l) => !l)];
}
/* ---------------- language hook (whole tree re-renders on change) ---------------- */
function useLang(): [Lang, (l: Lang) => void] {
const [lang, setLang] = useState<Lang>(LANG);
return [lang, (l) => { setLangGlobal(l); setLang(l); }];
}
/* ============================================================= */
export default function App() {
const [phase, setPhase] = useState<"loading" | "connect" | "main">("loading");
const [info, setInfo] = useState<P4Info | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [light, toggleTheme] = useTheme();
const [lang, setLang] = useLang();
const saved = useMemo(() => loadSession(), []);
// try to restore the last session from the on-disk p4 ticket
@ -81,7 +89,7 @@ export default function App() {
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
else
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme}
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang}
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
@ -121,26 +129,26 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title="Тема">{light ? I.sun : I.moon}</button>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="card">
<div className="clogo">{I.cube}</div>
<h2>Connect to Perforce</h2>
<div className="sub">Exbyte Depot sign in to your P4 server</div>
<h2>{t("Connect to Perforce")}</h2>
<div className="sub">{t("Exbyte Depot — sign in to your P4 server")}</div>
<div className="fld"><label>Server</label>
<div className="fld"><label>{t("Server")}</label>
<div className="inp">{I.monitor}<input value={server} onChange={(e) => setServer(e.target.value)} /></div></div>
<div className="fld"><label>User</label>
<div className="fld"><label>{t("User")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6"/><path d="M4 21a8 8 0 0 1 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
<input value={user} onChange={(e) => setUser(e.target.value)} onBlur={loadClients} /></div></div>
<div className="fld"><label>Password</label>
<div className="fld"><label>{t("Password")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
<div className="fld"><label>Workspace</label>
<div className="fld"><label>{t("Workspace")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>
{clients.length ? (
@ -148,13 +156,13 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
{clients.map((c) => <option key={c.client} value={c.client}>{c.client}{c.Root ? `${c.Root}` : ""}</option>)}
</select>
) : (
<input placeholder="имя workspace (не путь к папке!)" value={client} onChange={(e) => setClient(e.target.value)} />
<input placeholder={t("workspace name (not a folder path!)")} value={client} onChange={(e) => setClient(e.target.value)} />
)}
</div></div>
{err && <div className="err"><span></span><span>{err}</span></div>}
<button className="connect" disabled={busy || !password} onClick={connect}>
{busy ? <span className="spin">{I.spinner}</span> : I.arrow}{busy ? "Подключение…" : "Connect"}
{busy ? <span className="spin">{I.spinner}</span> : I.arrow}{busy ? t("Connecting…") : t("Connect")}
</button>
</div>
</div>
@ -173,8 +181,8 @@ type ModalState =
| { kind: "settings" }
| { kind: "about" };
function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
const [tab, setTab] = useState<Tab>("changes");
const [modal, setModal] = useState<ModalState | null>(null);
const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
@ -271,10 +279,10 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
let unlisten: (() => void) | undefined;
getCurrentWebview()
.onDragDropEvent(async (e) => {
const t = (e.payload as { type: string }).type;
if (t === "over") { setDragging(true); return; }
if (t === "leave") { setDragging(false); return; }
if (t === "drop") {
const dt = (e.payload as { type: string }).type;
if (dt === "over") { setDragging(true); return; }
if (dt === "leave") { setDragging(false); return; }
if (dt === "drop") {
setDragging(false);
const paths = (e.payload as { paths: string[] }).paths || [];
if (!paths.length) return;
@ -283,7 +291,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
for (const p of paths) {
try { n += (await p4.reconcile(p)).length; } catch (err) { flash(String(err), true); }
}
flash(n ? `В changelist добавлено/обновлено: ${n}` : "Нет изменений для добавления");
flash(n ? t("Added/updated in changelist: {n}", { n }) : t("No changes to add"));
await refresh();
}
})
@ -295,9 +303,9 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
async function revertAll() {
const list = files.map((f) => f.depotFile || "").filter(Boolean);
if (!list.length) return;
if (!(await confirm({ title: "Откатить все изменения?", body: `Будут отменены правки во всех ${list.length} файлах. Локальные изменения потеряются безвозвратно.`, confirm: "Откатить всё", danger: true }))) return;
if (!(await confirm({ title: t("Revert all changes?"), body: t("Edits in all {n} files will be undone. Local changes are lost permanently.", { n: list.length }), confirm: t("Revert all"), danger: true }))) return;
setBusy(true);
try { await p4.revert(list); flash("Все изменения откачены."); await refresh(); }
try { await p4.revert(list); flash(t("All changes reverted.")); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
@ -312,7 +320,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
}
async function openWorkspaces() {
if (!session) { flash("Нет данных сессии для списка workspace", true); return; }
if (!session) { flash(t("No session data for the workspace list"), true); return; }
try { setModal({ kind: "workspaces", items: await p4.clients(session.server, session.user), current: info?.clientName || "" }); }
catch (e) { flash(String(e), true); }
}
@ -325,7 +333,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
const scope = loadScope(client); // restore this workspace's saved working folder
setActivePath(scope);
await refresh(scope);
flash("Активный workspace: " + client);
flash(t("Active workspace: {c}", { c: client }));
} catch (e) { flash(String(e), true); setBusy(false); }
}
async function browseTo(path: string) {
@ -356,7 +364,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
async function getLatest() {
setBusy(true);
try { flash("Get Latest: " + await p4.sync(activePath)); await refresh(); }
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
@ -367,23 +375,23 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
// reveal a depot path / workspace root in Windows Explorer
async function reveal(target: string) {
if (!target) { flash("Путь не определён", true); return; }
if (!target) { flash(t("Path not set"), true); return; }
try { await p4.openInExplorer(target); }
catch (e) { flash(String(e), true); }
}
async function copyText(text: string, what: string) {
try { await navigator.clipboard.writeText(text); flash(`${what} скопировано`); }
catch { flash("Не удалось скопировать", true); }
try { await navigator.clipboard.writeText(text); flash(t("{what} copied", { what })); }
catch { flash(t("Copy failed"), true); }
}
// roll back an already-submitted changelist (Perforce `p4 undo` → new revert change)
async function revertChange(c: Change) {
const n = c.change || "";
if (!(await confirm({ title: `Откатить changelist #${n}?`, body: `Perforce создаст НОВЫЙ changelist, отменяющий изменения из #${n}, и засабмитит его на сервер. История сохранится (как «Revert» в GitHub Desktop).\n\${(c.desc || "").trim() || "без описания"}»`, confirm: `Откатить #${n}`, danger: true }))) return;
if (!(await confirm({ title: t("Revert changelist #{n}?", { n }), body: t("Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”", { n, desc: (c.desc || "").trim() || t("no description") }), confirm: t("Revert #{n}", { n }), danger: true }))) return;
setBusy(true);
try {
await p4.undoChange(n);
flash(`Changelist #${n} откачен новым changelist'ом.`);
flash(t("Changelist #{n} reverted by a new changelist.", { n }));
await loadHistory();
await refresh();
} catch (e) { flash(String(e), true); setBusy(false); }
@ -401,7 +409,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
setBusy(true);
try {
const cl = await p4.commit(message, list);
flash(`Закоммичено локально · changelist ${cl}. Нажми Submit вверху, чтобы отправить на сервер.`);
flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl }));
setDesc(""); setDescBody("");
await refresh();
} catch (e) { flash(String(e), true); setBusy(false); }
@ -411,7 +419,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
async function pushAll() {
if (!pending.length) return;
const n = pending.length;
if (!(await confirm({ title: `Submit — отправить на сервер`, body: `${n} changelist(ов) уйдут в депо Perforce и станут доступны команде. Это push — отменить нельзя.`, confirm: `Отправить ${n}` }))) return;
if (!(await confirm({ title: t("Submit — send to the server"), body: t("{n} changelist(s) will go to the Perforce depot and become available to the team. This is a push — it cannot be undone.", { n }), confirm: t("Submit {n}", { n }) }))) return;
setBusy(true);
setPushing({ done: 0, total: n });
const errors: string[] = [];
@ -423,15 +431,15 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
}
setPushing(null);
if (errors.length) flash(errors.join(" · "), true);
else flash(`Отправлено на сервер: ${n} changelist(ов).`);
else flash(t("Sent to the server: {n} changelist(s).", { n }));
await refresh();
}
async function doRevert(file: OpenedFile) {
const dp = file.depotFile || "";
if (!(await confirm({ title: "Откатить файл?", body: `${splitPath(dp).name}\n\окальные правки этого файла будут потеряны.`, confirm: "Откатить", danger: true }))) return;
if (!(await confirm({ title: t("Revert file?"), body: t("{name}\n\nLocal edits to this file will be lost.", { name: splitPath(dp).name }), confirm: t("Revert"), danger: true }))) return;
setBusy(true);
try { await p4.revert([dp]); flash("Откачено: " + splitPath(dp).name); await refresh(); }
try { await p4.revert([dp]); flash(t("Reverted: {name}", { name: splitPath(dp).name })); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
@ -453,11 +461,11 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
}
const menus: Record<string, { label: string; kb?: string; ext?: boolean; act?: () => void }[]> = {
File: [{ label: "Сменить workspace…", act: openWorkspaces }, { label: "Выбрать рабочую папку…", act: () => browseTo("") }, { label: "Отключиться", act: onDisconnect }],
Connection: [{ label: "Обновить", kb: "F5", act: () => refresh() }, { label: "Выбрать рабочую папку…", act: () => browseTo("") }],
Actions: [{ label: "Найти изменения заново", kb: "Ctrl+R", act: rescan }, { label: "Get Latest", kb: "Ctrl+G", act: getLatest }, { label: "Commit (локально)", act: doCommit }, { label: "Submit на сервер", kb: "Ctrl+S", act: pushAll }, { label: "Revert All…", act: revertAll }, { label: "Обновить", kb: "F5", act: () => refresh() }],
Tools: [{ label: "Настройки…", act: () => setModal({ kind: "settings" }) }],
Help: [{ label: "О программе", act: () => setModal({ kind: "about" }) }],
File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }],
Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }],
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }],
Tools: [{ label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }],
};
return (
@ -467,7 +475,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
<div className="menubar" onClick={(e) => e.stopPropagation()}>
{Object.entries(menus).map(([name, items]) => (
<div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
{name}
{t(name)}
<div className="dropdown">
{items.map((it) => (
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}>
@ -479,85 +487,85 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
))}
</div>
<div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
<button className="theme-btn" onClick={toggleTheme} title="Тема">{light ? I.sun : I.moon}</button>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="toolbar">
<div className="tzone" onClick={openWorkspaces} title="Сменить workspace · ПКМ — ещё"
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: "Открыть в проводнике", act: () => reveal(String(info?.clientRoot || "")) },
{ label: "Сменить workspace…", act: openWorkspaces },
{ label: "Скопировать имя", act: () => copyText(info?.clientName || "", "Имя workspace") },
{ label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
{ label: t("Switch workspace…"), act: openWorkspaces },
{ label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
])}>
<span className="ic">{I.monitor}</span>
<span className="tzmeta"><span className="lbl">Workspace</span><span className="val">{info?.clientName || "—"}</span></span>
<span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
<span className="chev">{I.chev}</span>
</div>
<div className="tgroup">
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title="Тянуть — изменить ширину" />
<div className="tzone" onClick={() => browseTo("")} title="Выбрать рабочую папку проекта · ПКМ — ещё"
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: "Открыть в проводнике", act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: "Выбрать другую папку…", act: () => browseTo("") },
{ label: "Скопировать путь", act: () => copyText(activePath || "//…", "Путь") },
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: t("Choose another folder…"), act: () => browseTo("") },
{ label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
])}>
<span className="ic">{I.branch}</span>
<span className="tzmeta"><span className="lbl">Рабочая папка</span><span className="val">{activePath || "весь workspace (//…)"}</span></span>
<span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
<span className="chev">{I.chev}</span>
</div>
{pending.length > 0 ? (
<div className="tzone push" onClick={pushAll} title="Отправить закоммиченное на сервер (push)">
<div className="tzone push" onClick={pushAll} title={t("Submit committed to the server (push)")}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 20V8M6 14l6-6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<span className="tzmeta"><span className="val">Submit на сервер</span><span className="sub">{pending.length} changelist(ов) push</span></span>
<span className="tzmeta"><span className="val">{t("Submit to server")}</span><span className="sub">{t("{n} changelist(s) → push", { n: pending.length })}</span></span>
</div>
) : (
<div className={"tzone sync" + (busy ? " busy" : "")} onClick={getLatest}>
<span className="ic">{I.sync}</span>
<span className="tzmeta"><span className="val">Get Latest</span><span className="sub">{busy ? "Работаю…" : "забрать с сервера (pull)"}</span></span>
<span className="tzmeta"><span className="val">{t("Get Latest")}</span><span className="sub">{busy ? t("Working…") : t("pull from server")}</span></span>
</div>
)}
</div>
</div>
<div className="main">
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title="Тянуть — изменить ширину" />
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
<div className="left">
<div className="tabs">
<button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>Changes <span className="badge">{uncommitted.length}</span></button>
<button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>History</button>
<button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>{t("Changes")} <span className="badge">{uncommitted.length}</span></button>
<button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>{t("History")}</button>
</div>
{tab === "changes" ? (
<>
<div className="filter">
<div className="box">{I.search}<input placeholder={`Filter ${uncommitted.length} files…`} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>
</div>
{pending.length > 0 && (
<div className="pushbar" onClick={pushAll} title="Отправить на сервер">
<div className="pushbar" onClick={pushAll} title={t("Submit to server")}>
<span className="pushdot" />
<span>{pending.length} changelist(ов) закоммичено готово к <b>Submit</b></span>
<span>{t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}</span>
<span className="pusharr"></span>
</div>
)}
{scanning && (
<div className="scanbar">
<span className="ldr" />
<span>Ищу изменения на диске{!activePath && " для всего workspace это долго — выбери рабочую папку проекта сверху"}</span>
<span>{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}</span>
</div>
)}
<div className="chhead">
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? "Снять выделение" : "Выделить все"}>{allChecked ? I.check : null}</span>
<span>{checked.size} из {view.length} выбрано</span>
<button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title="Перечитать открытые файлы (p4 opened)">
{I.sync}<span>{scanning ? "Сканирую…" : "Обновить"}</span>
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}>{allChecked ? I.check : null}</span>
<span>{t("{a} of {b} selected", { a: checked.size, b: view.length })}</span>
<button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
{I.sync}<span>{scanning ? t("Scanning…") : t("Refresh")}</span>
</button>
</div>
{view.length === 0 ? (
<div className="empty">
{scanning ? <><span className="ldr" />Ищу изменения на диске</> : <>
{scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}</> : <>
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
{busy ? "Загрузка…" : "Пока нет открытых файлов.\nМеняй ассеты в Unreal (Source Control → Perforce) — они появятся тут сами.\n\nПравил файлы мимо Perforce? Actions → «Найти изменения заново»."}
{busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")}
</>}
</div>
) : (
@ -565,23 +573,23 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
onToggle={(dp) => setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} />
)}
<div className="commit">
<input className="summary" placeholder="Кратко: что за изменение…" value={desc} onChange={(e) => setDesc(e.target.value)}
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
<textarea className="desc" placeholder="Описание (необязательно)…" value={descBody} onChange={(e) => setDescBody(e.target.value)}
<textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
<button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
{I.check}Commit changelist <b>· {checked.size} files</b>
{I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b>
</button>
<div className="addhint">Commit = локально (revertable). Отправка на сервер <b>Submit</b> вверху справа.</div>
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
</div>
</>
) : (
<HistoryList history={history} busy={busy} sel={selChange} onSelect={openChange}
onContext={(c, e) => openCtx(e, [
{ label: "Открыть этот changelist", act: () => openChange(c) },
{ label: `Откатить (revert) #${c.change}`, danger: true, act: () => revertChange(c) },
{ label: `Скопировать номер #${c.change}`, act: () => copyText(c.change || "", "Номер") },
{ label: "Скопировать описание", act: () => copyText((c.desc || "").trim(), "Описание") },
{ label: t("Open this changelist"), act: () => openChange(c) },
{ label: t("Revert (undo) #{n}", { n: c.change || "" }), danger: true, act: () => revertChange(c) },
{ label: t("Copy number #{n}", { n: c.change || "" }), act: () => copyText(c.change || "", t("Number")) },
{ label: t("Copy description"), act: () => copyText((c.desc || "").trim(), t("Description")) },
])} />
)}
</div>
@ -595,20 +603,20 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
<div className="dropzone">
<div className="dropinner">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
<span>Отпусти, чтобы добавить в Perforce</span>
<span>{t("Drop to add to Perforce")}</span>
</div>
</div>
)}
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme}
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{pushing && (
<div className="modal-back">
<div className="push-modal">
<div className="push-title"><span className="ldr" />Отправка на сервер</div>
<div className="push-count">Changelist {pushing.done} из {pushing.total}</div>
<div className="push-title"><span className="ldr" />{t("Submit — send to the server")}</div>
<div className="push-count">{t("Changelist {a} of {b}", { a: pushing.done, b: pushing.total })}</div>
<div className="push-track"><div className="push-fill" style={{ width: `${Math.round((pushing.done / pushing.total) * 100)}%` }} /></div>
<div className="push-hint">Крупные ассеты заливаются дольше не закрывай окно.</div>
<div className="push-hint">{t("Large assets take longer to upload — dont close the window.")}</div>
</div>
</div>
)}
@ -619,8 +627,8 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
}
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
function AppModal({ modal, info, light, toggleTheme, lang, setLang, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
@ -634,9 +642,9 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
<div className="modal-back" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
{modal.kind === "workspaces" && (<>
<div className="picker-head">{folder}<h3>Выбор workspace</h3>{X}</div>
<div className="picker-head">{folder}<h3>{t("Choose workspace")}</h3>{X}</div>
<div className="picker-list">
{modal.items.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 20 }}>Нет доступных workspace</div>}
{modal.items.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 20 }}>{t("No workspaces available")}</div>}
{modal.items.map((c) => (
<div key={c.client} className={"picker-item" + (c.client === modal.current ? " on" : "")} onClick={() => onPickWorkspace(c.client || "")}>
<span className="pi-ic">{folder}</span>
@ -644,7 +652,7 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
</div>
))}
</div>
<div className="picker-foot">Активный: {modal.current || "—"}</div>
<div className="picker-foot">{t("Active: {c}", { c: modal.current || "—" })}</div>
</>)}
{modal.kind === "scope" && (() => {
@ -653,7 +661,7 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
const parentPath = parts.length ? "//" + parts.slice(0, -1).join("/") : "";
const up = parts.length > 0;
return (<>
<div className="picker-head">{depotIc}<h3>Рабочая папка</h3>{X}</div>
<div className="picker-head">{depotIc}<h3>{t("Working folder")}</h3>{X}</div>
<div style={{ padding: "10px 16px", borderBottom: "1px solid var(--border-soft)" }}>
<div className="crumb">
<span className="seg" onClick={() => onBrowse("")}>//</span>
@ -666,10 +674,10 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
{up && (
<div className="picker-item" onClick={() => onBrowse(parentPath)}>
<span className="pi-ic up"><svg viewBox="0 0 24 24" fill="none"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<span className="pi-body"><span className="pi-name up">..&nbsp; вверх</span></span>
<span className="pi-body"><span className="pi-name up">{t(".. up")}</span></span>
</div>
)}
{modal.dirs.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 18 }}>Вложенных папок нет</div>}
{modal.dirs.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 18 }}>{t("No subfolders")}</div>}
{modal.dirs.map((d) => (
<div key={d.dir} className="picker-item" onClick={() => onBrowse(d.dir || "")}>
<span className="pi-ic">{folder}</span>
@ -679,20 +687,29 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
))}
</div>
<button className="scope-choose" onClick={() => onChoose(path)}>
{path ? `Работать в: ${path}` : "Работать во всём workspace (//…)"}
{path ? t("Work in: {path}", { path }) : t("Work in the whole workspace (//…)")}
</button>
</>);
})()}
{modal.kind === "settings" && (<>
<div className="picker-head"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" /><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3" /></svg><h3>Настройки</h3>{X}</div>
<div className="picker-head"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" /><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3" /></svg><h3>{t("Settings")}</h3>{X}</div>
<div className="info-body">
<div className="info-row"><span className="rk">Тема</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? "Светлая ☀" : "Тёмная ☾"}</button></div>
<div className="info-row"><span className="rk">Сервер</span><span className="rv">{info?.serverAddress || "—"}</span></div>
<div className="info-row"><span className="rk">Пользователь</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">Workspace</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">Версия сервера</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
<div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>Отключиться</button></div>
<div className="info-row"><span className="rk">{t("Language")}</span>
<div className="langsel">
{LANGS.map((l) => (
<button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
<span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
</button>
))}
</div>
</div>
<div className="info-row"><span className="rk">{t("Theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{info?.serverAddress || "—"}</span></div>
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
<div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>{t("Disconnect")}</button></div>
</div>
</>)}
@ -702,8 +719,8 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
<div style={{ width: 60, height: 60, margin: "6px auto 14px", borderRadius: 16, background: "linear-gradient(145deg,var(--accent-2),var(--accent-deep))", display: "grid", placeItems: "center", boxShadow: "0 0 30px rgba(124,110,246,.5)" }}>
<svg viewBox="0 0 24 24" width="30" height="30" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="#fff" strokeWidth="1.5" strokeLinejoin="round" /><path d="m3 7 9 5 9-5M12 12v10" stroke="#fff" strokeWidth="1.5" /></svg>
</div>
<p style={{ marginBottom: 8 }}><b>Exbyte Depot</b> нативный клиент Perforce</p>
<p style={{ fontSize: 12, color: "var(--faint)" }}>Tauri + React · three.js · v0.1.0<br />Собственная разработка Exbyte Studios</p>
<p style={{ marginBottom: 8 }}>{t("Exbyte Depot — native Perforce client")}</p>
<p style={{ fontSize: 12, color: "var(--faint)" }}>Tauri + React · three.js · v0.1.0<br />{t("Built by Exbyte Studios")}</p>
</div>
</>)}
</div>
@ -749,7 +766,7 @@ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string
<h3>{title}</h3>
<p>{body}</p>
<div className="modal-actions">
<button className="mbtn ghost" onClick={() => onClose(false)}>Отмена</button>
<button className="mbtn ghost" onClick={() => onClose(false)}>{t("Cancel")}</button>
<button className={"mbtn " + (danger ? "danger" : "primary")} onClick={() => onClose(true)} autoFocus>{confirm}</button>
</div>
</div>
@ -775,8 +792,8 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
// jump back to top whenever a different changelist loads
useEffect(() => { if (ref.current) ref.current.scrollTop = 0; setScroll(0); }, [detail?.change]);
if (busy) return <div className="right"><div className="nofile"><span className="ldr" /><span>Загрузка списка файлов</span></div></div>;
if (!detail) return <div className="right"><div className="nofile">{I.clock}<span>Выберите changelist в History</span></div></div>;
if (busy) return <div className="right"><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
if (!detail) return <div className="right"><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;
const first = Math.max(0, Math.floor(scroll / CROW) - 6);
const last = Math.min(fileRows.length, Math.ceil((scroll + h) / CROW) + 6);
@ -797,8 +814,8 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
<div className="diffhead">
<span className="rev" style={{ fontSize: 15, fontWeight: 700 }}>#{detail.change}</span>
<span className="ttl">
<span className="n">{(detail.desc || "").trim() || "(без описания)"}</span>
<span className="m">{detail.user} · {fmtTime(detail.time)} · {fileRows.length} файл(ов)</span>
<span className="n">{(detail.desc || "").trim() || t("(no description)")}</span>
<span className="m">{t("{user} · {time} · {n} file(s)", { user: detail.user || "", time: fmtTime(detail.time), n: fileRows.length })}</span>
</span>
</div>
<div className="files" ref={ref}><div className="vspace" style={{ height: fileRows.length * CROW }}>{rows}</div></div>
@ -808,15 +825,15 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
/* ---------------- history list ---------------- */
function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
if (busy && !history.length) return <div className="empty">Загрузка истории</div>;
if (!history.length) return <div className="empty">Нет засабмиченных изменений.</div>;
if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
return (
<div className="files" style={{ position: "relative" }}>
{history.map((c) => (
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
<span className="hnum">#{c.change}</span>
<span className="hbody">
<span className="hdesc">{(c.desc || "").trim() || "(без описания)"}</span>
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">{c.user} · {fmtTime(c.time)}</span>
</span>
</div>
@ -895,7 +912,7 @@ function FileList({ files, sel, checked, onSelect, onToggle }:
/* ---------------- preview panel ---------------- */
function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void }) {
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>Выберите файл слева</span></div></div>;
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>{t("Select a file on the left")}</span></div></div>;
const dp = file.depotFile || "";
const { name, dir } = splitPath(dp);
const st = statusOf(file.action);
@ -911,7 +928,7 @@ function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFi
<span className="m">{file.action} · <span className="rev">{rev}</span> · {file.type}</span>
</span>
<span className="tools">
<span className="gbtn" onClick={() => onRevert(file)}>{I.revert}Revert</span>
<span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>
</span>
</div>
@ -933,13 +950,13 @@ function DiffView({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
useEffect(() => {
let live = true; setText(null); setErr("");
p4.diff(dp).then((t) => live && setText(t)).catch((e) => live && setErr(String(e)));
p4.diff(dp).then((txt) => live && setText(txt)).catch((e) => live && setErr(String(e)));
return () => { live = false; };
}, [dp]);
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
if (text === null) return <div className="nofile">Загрузка diff</div>;
if (!text.trim()) return <div className="nofile">{I.check}<span>Нет текстовых отличий (или бинарный файл).</span></div>;
if (text === null) return <div className="nofile">{t("Loading diff…")}</div>;
if (!text.trim()) return <div className="nofile">{I.check}<span>{t("No text differences (or binary file).")}</span></div>;
const lines = text.split("\n");
return (
@ -974,14 +991,14 @@ function ImageViewer({ depot, name }: { depot: string; name: string }) {
return (
<div className="asset">
<div className="stage">
<span className="lbl2"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/></svg>Image</span>
<span className="lbl2"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/></svg>{t("Image")}</span>
{err ? <div className="viewer-msg err">{err}</div>
: url ? <img src={url} alt={name} style={{ maxWidth: "90%", maxHeight: "90%", borderRadius: 10, boxShadow: "0 20px 50px -20px #000" }}
onLoad={(e) => setDim(`${(e.target as HTMLImageElement).naturalWidth}×${(e.target as HTMLImageElement).naturalHeight}`)} />
: <div className="viewer-msg"><span className="ldr" />Загрузка</div>}
: <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>}
</div>
<div className="assetmeta">
<Mi k="Файл" v={name} /><Mi k="Разрешение" v={dim || "—"} /><Mi k="Формат" v={(name.split(".").pop() || "").toUpperCase()} /><Mi k="Источник" v="working copy" acc />
<Mi k={t("File")} v={name} /><Mi k={t("Resolution")} v={dim || "—"} /><Mi k={t("Format")} v={(name.split(".").pop() || "").toUpperCase()} /><Mi k={t("Source")} v={t("working copy")} acc />
</div>
</div>
);
@ -1009,18 +1026,18 @@ function UassetPreview({ file }: { file: OpenedFile }) {
<div className="asset">
<div className="stage">
{thumb ? (<>
<span className="lbl2">{I.cube}Unreal · вшитая миниатюра</span>
<span className="lbl2">{I.cube}{t("Unreal · embedded thumbnail")}</span>
<img src={thumb} alt={name} style={{ maxWidth: "82%", maxHeight: "82%", borderRadius: 12, boxShadow: "0 20px 50px -20px #000" }} />
</>) : (
<div className="uasset-card">
<div className="uasset-ic">{I.cube}</div>
<div className="uasset-name">{name}</div>
<div className="uasset-note">{!tried ? "Читаю ассет…" : "В этом ассете нет вшитой миниатюры. Интерактивный 3D работает для FBX / OBJ / GLB / GLTF / STL — экспортни модель в один из этих форматов."}</div>
<div className="uasset-note">{!tried ? t("Reading asset…") : t("This asset has no embedded thumbnail. Interactive 3D works for FBX / OBJ / GLB / GLTF / STL — export the model to one of these formats.")}</div>
</div>
)}
</div>
<div className="assetmeta">
<Mi k="Тип" v={file.type || "binary"} /><Mi k="Превью" v={thumb ? "миниатюра" : (tried ? "нет" : "…")} /><Mi k="Ревизия" v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
<Mi k={t("Type")} v={file.type || "binary"} /><Mi k={t("Preview")} v={thumb ? t("thumbnail") : (tried ? t("none") : "…")} /><Mi k={t("Revision")} v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
</div>
</div>
);

View File

@ -10,6 +10,7 @@ import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
import { ThreeMFLoader } from "three/examples/jsm/loaders/3MFLoader.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { p4 } from "./p4";
import { t } from "./i18n";
type Api = { toggleWire: (b: boolean) => void; toggleGrid: (b: boolean) => void; reset: () => void };
@ -143,11 +144,11 @@ export default function ModelViewer({ depot, name }: { depot: string; name: stri
if (ext === "glb" || ext === "gltf") new GLTFLoader(manager).parse(buf, "", (g) => !disposed && place(g.scene), fail);
else if (ext === "obj") place(new OBJLoader(manager).parse(new TextDecoder().decode(buf)));
else if (ext === "fbx") place(new FBXLoader(manager).parse(buf, ""));
else if (ext === "dae") { const c = new ColladaLoader(manager).parse(new TextDecoder().decode(buf), ""); if (c && c.scene) place(c.scene); else fail("Collada: пустая сцена"); }
else if (ext === "dae") { const c = new ColladaLoader(manager).parse(new TextDecoder().decode(buf), ""); if (c && c.scene) place(c.scene); else fail(t("Collada: empty scene")); }
else if (ext === "3mf") place(new ThreeMFLoader(manager).parse(buf));
else if (ext === "stl") place(new THREE.Mesh(new STLLoader().parse(buf), new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5 })));
else if (ext === "ply") { const g = new PLYLoader().parse(buf); g.computeVertexNormals(); place(new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5, vertexColors: !!g.getAttribute("color") }))); }
else { setErr("Формат не поддерживается вьювером"); setStatus("error"); }
else { setErr(t("Format not supported by the viewer")); setStatus("error"); }
} catch (e) { fail(e); }
})();
@ -185,20 +186,20 @@ export default function ModelViewer({ depot, name }: { depot: string; name: stri
</span>
{status === "ok" && (
<div className="v3d-tools">
<button className={"v3d-btn" + (wire ? " on" : "")} onClick={() => { setWire(!wire); api.current?.toggleWire(!wire); }} title="Wireframe"></button>
<button className={"v3d-btn" + (grid ? " on" : "")} onClick={() => { setGrid(!grid); api.current?.toggleGrid(!grid); }} title="Сетка"></button>
<button className="v3d-btn" onClick={() => api.current?.reset()} title="Сбросить вид"></button>
<button className={"v3d-btn" + (wire ? " on" : "")} onClick={() => { setWire(!wire); api.current?.toggleWire(!wire); }} title={t("Wireframe")}></button>
<button className={"v3d-btn" + (grid ? " on" : "")} onClick={() => { setGrid(!grid); api.current?.toggleGrid(!grid); }} title={t("Grid")}></button>
<button className="v3d-btn" onClick={() => api.current?.reset()} title={t("Reset view")}></button>
</div>
)}
{status === "loading" && <div className="viewer-msg"><span className="ldr" />Загрузка модели</div>}
{status === "loading" && <div className="viewer-msg"><span className="ldr" />{t("Loading model…")}</div>}
{status === "error" && <div className="viewer-msg err">{err}</div>}
{status === "ok" && <span className="hint">тащи вращать · колесо зум · ПКМ панорама</span>}
{status === "ok" && <span className="hint">{t("drag — rotate · wheel — zoom · right-click — pan")}</span>}
</div>
<div className="assetmeta">
<div className="mi"><span className="k">Треугольники</span><span className="v">{fmt(stats.tris)}</span></div>
<div className="mi"><span className="k">Вершины</span><span className="v">{fmt(stats.verts)}</span></div>
<div className="mi"><span className="k">Меши · Материалы</span><span className="v">{stats.meshes} · {stats.mats}</span></div>
<div className="mi"><span className="k">Рендер</span><span className="v acc">three.js · PBR</span></div>
<div className="mi"><span className="k">{t("Triangles")}</span><span className="v">{fmt(stats.tris)}</span></div>
<div className="mi"><span className="k">{t("Vertices")}</span><span className="v">{fmt(stats.verts)}</span></div>
<div className="mi"><span className="k">{t("Meshes · Materials")}</span><span className="v">{stats.meshes} · {stats.mats}</span></div>
<div className="mi"><span className="k">{t("Render")}</span><span className="v acc">three.js · PBR</span></div>
</div>
</div>
);

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { t } from "./i18n";
type Phase = "idle" | "available" | "downloading" | "done" | "error";
@ -51,11 +52,11 @@ export default function UpdateBanner() {
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
</span>
<div className="upd-ttl">
<b>Доступно обновление</b>
<b>{t("Update available")}</b>
<span>Exbyte Depot v{update.version}</span>
</div>
{(phase === "available" || phase === "error") && (
<button className="upd-x" onClick={() => setHidden(true)} title="Позже">
<button className="upd-x" onClick={() => setHidden(true)} title={t("Later")}>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
</button>
)}
@ -66,16 +67,16 @@ export default function UpdateBanner() {
{phase === "downloading" && (
<div className="upd-prog">
<div className="upd-track"><div className="upd-fill" style={{ width: `${pct}%` }} /></div>
<span>Загрузка {pct}%</span>
<span>{t("Downloading… {pct}%", { pct })}</span>
</div>
)}
{phase === "done" && <div className="upd-notes">Установлено перезапускаю</div>}
{phase === "done" && <div className="upd-notes">{t("Installed — restarting…")}</div>}
{phase === "error" && <div className="upd-notes err">{err}</div>}
{phase === "available" && (
<div className="upd-actions">
<button className="upd-later" onClick={() => setHidden(true)}>Позже</button>
<button className="upd-go" onClick={install}>Обновить сейчас</button>
<button className="upd-later" onClick={() => setHidden(true)}>{t("Later")}</button>
<button className="upd-go" onClick={install}>{t("Update now")}</button>
</div>
)}
</div>

220
src/i18n.ts Normal file
View File

@ -0,0 +1,220 @@
// Lightweight i18n. English strings are the keys; translations live per-language.
// t() reads the module-level LANG, so any component re-rendered after a language
// change (the whole tree re-renders from App) picks up the new language.
export type Lang = "en" | "ru" | "de" | "fr" | "es";
export const LANGS: { code: Lang; name: string; flag: string }[] = [
{ code: "en", name: "English", flag: "🇬🇧" },
{ code: "ru", name: "Русский", flag: "🇷🇺" },
{ code: "de", name: "Deutsch", flag: "🇩🇪" },
{ code: "fr", name: "Français", flag: "🇫🇷" },
{ code: "es", name: "Español", flag: "🇪🇸" },
];
function load(): Lang {
try { const l = localStorage.getItem("exd-lang") as Lang; return LANGS.some((x) => x.code === l) ? l : "en"; }
catch { return "en"; }
}
export let LANG: Lang = load();
export function setLangGlobal(l: Lang) { LANG = l; try { localStorage.setItem("exd-lang", l); } catch {} }
type Tr = Partial<Record<Lang, string>>;
const D: Record<string, Tr> = {
// window controls
"Minimize": { ru: "Свернуть", de: "Minimieren", fr: "Réduire", es: "Minimizar" },
"Maximize": { ru: "Развернуть", de: "Maximieren", fr: "Agrandir", es: "Maximizar" },
"Close": { ru: "Закрыть", de: "Schließen", fr: "Fermer", es: "Cerrar" },
"Theme": { ru: "Тема", de: "Thema", fr: "Thème", es: "Tema" },
"Restoring session…": { ru: "Восстановление сессии…", de: "Sitzung wird wiederhergestellt…", fr: "Restauration de la session…", es: "Restaurando sesión…" },
// connect screen
"Connect to Perforce": { ru: "Подключение к Perforce", de: "Mit Perforce verbinden", fr: "Se connecter à Perforce", es: "Conectar a Perforce" },
"Exbyte Depot — sign in to your P4 server": { ru: "Exbyte Depot — вход на ваш сервер P4", de: "Exbyte Depot — bei deinem P4-Server anmelden", fr: "Exbyte Depot — connectez-vous à votre serveur P4", es: "Exbyte Depot — inicia sesión en tu servidor P4" },
"Server": { ru: "Сервер", de: "Server", fr: "Serveur", es: "Servidor" },
"User": { ru: "Пользователь", de: "Benutzer", fr: "Utilisateur", es: "Usuario" },
"Password": { ru: "Пароль", de: "Passwort", fr: "Mot de passe", es: "Contraseña" },
"Workspace": { ru: "Workspace", de: "Workspace", fr: "Workspace", es: "Workspace" },
"workspace name (not a folder path!)": { ru: "имя workspace (не путь к папке!)", de: "Workspace-Name (kein Ordnerpfad!)", fr: "nom du workspace (pas un chemin !)", es: "nombre del workspace (¡no una ruta!)" },
"Connecting…": { ru: "Подключение…", de: "Verbinde…", fr: "Connexion…", es: "Conectando…" },
"Connect": { ru: "Подключиться", de: "Verbinden", fr: "Se connecter", es: "Conectar" },
// toolbar
"Switch workspace · right-click for more": { ru: "Сменить workspace · ПКМ — ещё", de: "Workspace wechseln · Rechtsklick für mehr", fr: "Changer de workspace · clic droit pour plus", es: "Cambiar workspace · clic derecho para más" },
"Choose the project working folder · right-click for more": { ru: "Выбрать рабочую папку проекта · ПКМ — ещё", de: "Projekt-Arbeitsordner wählen · Rechtsklick für mehr", fr: "Choisir le dossier de travail · clic droit pour plus", es: "Elegir carpeta de trabajo · clic derecho para más" },
"Open in Explorer": { ru: "Открыть в проводнике", de: "Im Explorer öffnen", fr: "Ouvrir dans lexplorateur", es: "Abrir en el explorador" },
"Switch workspace…": { ru: "Сменить workspace…", de: "Workspace wechseln…", fr: "Changer de workspace…", es: "Cambiar workspace…" },
"Copy name": { ru: "Скопировать имя", de: "Namen kopieren", fr: "Copier le nom", es: "Copiar nombre" },
"Choose another folder…": { ru: "Выбрать другую папку…", de: "Anderen Ordner wählen…", fr: "Choisir un autre dossier…", es: "Elegir otra carpeta…" },
"Copy path": { ru: "Скопировать путь", de: "Pfad kopieren", fr: "Copier le chemin", es: "Copiar ruta" },
"Working folder": { ru: "Рабочая папка", de: "Arbeitsordner", fr: "Dossier de travail", es: "Carpeta de trabajo" },
"whole workspace (//…)": { ru: "весь workspace (//…)", de: "gesamter Workspace (//…)", fr: "workspace entier (//…)", es: "todo el workspace (//…)" },
"Drag to resize": { ru: "Тянуть — изменить ширину", de: "Ziehen zum Anpassen", fr: "Glisser pour redimensionner", es: "Arrastra para redimensionar" },
"Submit committed to the server (push)": { ru: "Отправить закоммиченное на сервер (push)", de: "Committed an Server senden (push)", fr: "Envoyer les commits au serveur (push)", es: "Enviar lo confirmado al servidor (push)" },
"Submit to server": { ru: "Submit на сервер", de: "An Server senden", fr: "Envoyer au serveur", es: "Enviar al servidor" },
"{n} changelist(s) → push": { ru: "{n} changelist(ов) → push", de: "{n} Changelist(s) → push", fr: "{n} changelist(s) → push", es: "{n} changelist(s) → push" },
"Get Latest": { ru: "Get Latest", de: "Get Latest", fr: "Get Latest", es: "Get Latest" },
"Working…": { ru: "Работаю…", de: "Arbeite…", fr: "En cours…", es: "Trabajando…" },
"pull from server": { ru: "забрать с сервера (pull)", de: "vom Server holen (pull)", fr: "récupérer du serveur (pull)", es: "traer del servidor (pull)" },
// tabs / list
"Changes": { ru: "Changes", de: "Änderungen", fr: "Modifications", es: "Cambios" },
"History": { ru: "History", de: "Verlauf", fr: "Historique", es: "Historial" },
"Filter {n} files…": { ru: "Фильтр {n} файлов…", de: "{n} Dateien filtern…", fr: "Filtrer {n} fichiers…", es: "Filtrar {n} archivos…" },
"{n} changelist(s) committed — ready to Submit": { ru: "{n} changelist(ов) закоммичено — готово к Submit", de: "{n} Changelist(s) committed — bereit zum Submit", fr: "{n} changelist(s) validés — prêts à envoyer", es: "{n} changelist(s) confirmados — listos para enviar" },
"Scanning disk for changes…": { ru: "Ищу изменения на диске…", de: "Suche Änderungen auf der Festplatte…", fr: "Recherche de modifications sur le disque…", es: "Buscando cambios en el disco…" },
" for the whole workspace this is slow — pick a project folder above": { ru: " для всего workspace это долго — выбери рабочую папку проекта сверху", de: " für den ganzen Workspace ist das langsam — wähle oben einen Projektordner", fr: " pour tout le workspace cest lent — choisissez un dossier de projet en haut", es: " para todo el workspace es lento — elige una carpeta de proyecto arriba" },
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },
"Select all": { ru: "Выделить все", de: "Alle auswählen", fr: "Tout sélectionner", es: "Seleccionar todo" },
"Deselect all": { ru: "Снять выделение", de: "Auswahl aufheben", fr: "Tout désélectionner", es: "Deseleccionar todo" },
"Refresh": { ru: "Обновить", de: "Aktualisieren", fr: "Rafraîchir", es: "Actualizar" },
"Scanning…": { ru: "Сканирую…", de: "Scanne…", fr: "Analyse…", es: "Analizando…" },
"Re-read open files (p4 opened)": { ru: "Перечитать открытые файлы (p4 opened)", de: "Offene Dateien neu lesen (p4 opened)", fr: "Relire les fichiers ouverts (p4 opened)", es: "Releer archivos abiertos (p4 opened)" },
"Loading…": { ru: "Загрузка…", de: "Lädt…", fr: "Chargement…", es: "Cargando…" },
"No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.": {
ru: "Пока нет открытых файлов.\nМеняй ассеты в Unreal (Source Control → Perforce) — они появятся тут сами.\n\nПравил файлы мимо Perforce? Actions → «Найти изменения заново».",
de: "Noch keine offenen Dateien.\nBearbeite Assets in Unreal (Source Control → Perforce) — sie erscheinen hier von selbst.\n\nDateien außerhalb von Perforce geändert? Aktionen → „Änderungen neu suchen“.",
fr: "Aucun fichier ouvert pour linstant.\nModifiez les assets dans Unreal (Source Control → Perforce) — ils apparaîtront ici automatiquement.\n\nFichiers modifiés hors Perforce ? Actions → « Rechercher les modifications ».",
es: "Aún no hay archivos abiertos.\nEdita assets en Unreal (Source Control → Perforce) — aparecerán aquí solos.\n\n¿Editaste archivos fuera de Perforce? Acciones → «Volver a buscar cambios».",
},
// commit box
"Summary: what changed…": { ru: "Кратко: что за изменение…", de: "Kurz: was wurde geändert…", fr: "Résumé : quel changement…", es: "Resumen: qué cambió…" },
"Description (optional)…": { ru: "Описание (необязательно)…", de: "Beschreibung (optional)…", fr: "Description (facultatif)…", es: "Descripción (opcional)…" },
"Commit changelist": { ru: "Commit changelist", de: "Changelist committen", fr: "Valider le changelist", es: "Confirmar changelist" },
"· {n} files": { ru: "· {n} файлов", de: "· {n} Dateien", fr: "· {n} fichiers", es: "· {n} archivos" },
"Commit = local (revertable). Send to the server — Submit, top-right.": { ru: "Commit = локально (revertable). Отправка на сервер — Submit вверху справа.", de: "Commit = lokal (rückgängig machbar). Zum Server senden — Submit oben rechts.", fr: "Commit = local (réversible). Envoyer au serveur — Submit en haut à droite.", es: "Commit = local (reversible). Enviar al servidor — Submit arriba a la derecha." },
// menus
"File": { ru: "Файл", de: "Datei", fr: "Fichier", es: "Archivo" },
"Connection": { ru: "Подключение", de: "Verbindung", fr: "Connexion", es: "Conexión" },
"Actions": { ru: "Действия", de: "Aktionen", fr: "Actions", es: "Acciones" },
"Tools": { ru: "Инструменты", de: "Werkzeuge", fr: "Outils", es: "Herramientas" },
"Help": { ru: "Справка", de: "Hilfe", fr: "Aide", es: "Ayuda" },
"Choose working folder…": { ru: "Выбрать рабочую папку…", de: "Arbeitsordner wählen…", fr: "Choisir le dossier de travail…", es: "Elegir carpeta de trabajo…" },
"Disconnect": { ru: "Отключиться", de: "Trennen", fr: "Se déconnecter", es: "Desconectar" },
"Rescan changes": { ru: "Найти изменения заново", de: "Änderungen neu suchen", fr: "Rechercher les modifications", es: "Volver a buscar cambios" },
"Commit (local)": { ru: "Commit (локально)", de: "Commit (lokal)", fr: "Commit (local)", es: "Commit (local)" },
"Revert All…": { ru: "Откатить всё…", de: "Alles zurücksetzen…", fr: "Tout annuler…", es: "Revertir todo…" },
"Settings…": { ru: "Настройки…", de: "Einstellungen…", fr: "Paramètres…", es: "Ajustes…" },
"About": { ru: "О программе", de: "Über", fr: "À propos", es: "Acerca de" },
// confirm dialogs
"Revert all changes?": { ru: "Откатить все изменения?", de: "Alle Änderungen zurücksetzen?", fr: "Annuler toutes les modifications ?", es: "¿Revertir todos los cambios?" },
"Edits in all {n} files will be undone. Local changes are lost permanently.": { ru: "Будут отменены правки во всех {n} файлах. Локальные изменения потеряются безвозвратно.", de: "Änderungen in allen {n} Dateien werden verworfen. Lokale Änderungen gehen endgültig verloren.", fr: "Les modifications de {n} fichiers seront annulées. Les changements locaux sont perdus définitivement.", es: "Se desharán los cambios en {n} archivos. Los cambios locales se pierden para siempre." },
"Revert all": { ru: "Откатить всё", de: "Alles zurücksetzen", fr: "Tout annuler", es: "Revertir todo" },
"Submit — send to the server": { ru: "Submit — отправить на сервер", de: "Submit — an Server senden", fr: "Submit — envoyer au serveur", es: "Submit — enviar al servidor" },
"{n} changelist(s) will go to the Perforce depot and become available to the team. This is a push — it cannot be undone.": { ru: "{n} changelist(ов) уйдут в депо Perforce и станут доступны команде. Это push — отменить нельзя.", de: "{n} Changelist(s) gehen ins Perforce-Depot und werden für das Team verfügbar. Das ist ein Push — nicht widerrufbar.", fr: "{n} changelist(s) iront dans le dépôt Perforce et seront disponibles pour léquipe. Cest un push — irréversible.", es: "{n} changelist(s) irán al depósito Perforce y estarán disponibles para el equipo. Es un push — no se puede deshacer." },
"Submit {n}": { ru: "Отправить {n}", de: "{n} senden", fr: "Envoyer {n}", es: "Enviar {n}" },
"Changelist {a} of {b}": { ru: "Changelist {a} из {b}", de: "Changelist {a} von {b}", fr: "Changelist {a} sur {b}", es: "Changelist {a} de {b}" },
"Large assets take longer to upload — dont close the window.": { ru: "Крупные ассеты заливаются дольше — не закрывай окно.", de: "Große Assets brauchen länger zum Hochladen — schließe das Fenster nicht.", fr: "Les gros assets prennent plus de temps à envoyer — ne fermez pas la fenêtre.", es: "Los assets grandes tardan más en subir — no cierres la ventana." },
"Revert file?": { ru: "Откатить файл?", de: "Datei zurücksetzen?", fr: "Annuler le fichier ?", es: "¿Revertir archivo?" },
"{name}\n\nLocal edits to this file will be lost.": { ru: "{name}\n\nЛокальные правки этого файла будут потеряны.", de: "{name}\n\nLokale Änderungen an dieser Datei gehen verloren.", fr: "{name}\n\nLes modifications locales de ce fichier seront perdues.", es: "{name}\n\nLos cambios locales de este archivo se perderán." },
"Revert": { ru: "Откатить", de: "Zurücksetzen", fr: "Annuler", es: "Revertir" },
"Revert changelist #{n}?": { ru: "Откатить changelist #{n}?", de: "Changelist #{n} zurücksetzen?", fr: "Annuler le changelist #{n} ?", es: "¿Revertir changelist #{n}?" },
"Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”": { ru: "Perforce создаст НОВЫЙ changelist, отменяющий изменения из #{n}, и засабмитит его на сервер. История сохранится (как «Revert» в GitHub Desktop).\n\n«{desc}»", de: "Perforce erstellt eine NEUE Changelist, die #{n} rückgängig macht, und sendet sie an den Server. Der Verlauf bleibt erhalten (wie „Revert“ in GitHub Desktop).\n\n„{desc}“", fr: "Perforce créera un NOUVEAU changelist qui annule #{n} et lenverra au serveur. Lhistorique est conservé (comme « Revert » dans GitHub Desktop).\n\n« {desc} »", es: "Perforce creará un NUEVO changelist que deshace #{n} y lo enviará al servidor. El historial se conserva (como «Revert» en GitHub Desktop).\n\n«{desc}»" },
"Revert #{n}": { ru: "Откатить #{n}", de: "#{n} zurücksetzen", fr: "Annuler #{n}", es: "Revertir #{n}" },
"no description": { ru: "без описания", de: "keine Beschreibung", fr: "sans description", es: "sin descripción" },
"Cancel": { ru: "Отмена", de: "Abbrechen", fr: "Annuler", es: "Cancelar" },
// toasts
"No session data for the workspace list": { ru: "Нет данных сессии для списка workspace", de: "Keine Sitzungsdaten für die Workspace-Liste", fr: "Aucune donnée de session pour la liste des workspaces", es: "No hay datos de sesión para la lista de workspaces" },
"Active workspace: {c}": { ru: "Активный workspace: {c}", de: "Aktiver Workspace: {c}", fr: "Workspace actif : {c}", es: "Workspace activo: {c}" },
"Get Latest: {msg}": { ru: "Get Latest: {msg}", de: "Get Latest: {msg}", fr: "Get Latest : {msg}", es: "Get Latest: {msg}" },
"Added/updated in changelist: {n}": { ru: "В changelist добавлено/обновлено: {n}", de: "Zur Changelist hinzugefügt/aktualisiert: {n}", fr: "Ajouté/mis à jour dans le changelist : {n}", es: "Añadido/actualizado en el changelist: {n}" },
"No changes to add": { ru: "Нет изменений для добавления", de: "Keine Änderungen zum Hinzufügen", fr: "Aucune modification à ajouter", es: "No hay cambios para añadir" },
"All changes reverted.": { ru: "Все изменения откачены.", de: "Alle Änderungen zurückgesetzt.", fr: "Toutes les modifications annulées.", es: "Todos los cambios revertidos." },
"Committed locally · changelist {cl}. Press Submit at the top to send it to the server.": { ru: "Закоммичено локально · changelist {cl}. Нажми Submit вверху, чтобы отправить на сервер.", de: "Lokal committed · Changelist {cl}. Drücke oben Submit, um an den Server zu senden.", fr: "Validé localement · changelist {cl}. Appuyez sur Submit en haut pour lenvoyer au serveur.", es: "Confirmado localmente · changelist {cl}. Pulsa Submit arriba para enviarlo al servidor." },
"Sent to the server: {n} changelist(s).": { ru: "Отправлено на сервер: {n} changelist(ов).", de: "An Server gesendet: {n} Changelist(s).", fr: "Envoyé au serveur : {n} changelist(s).", es: "Enviado al servidor: {n} changelist(s)." },
"Reverted: {name}": { ru: "Откачено: {name}", de: "Zurückgesetzt: {name}", fr: "Annulé : {name}", es: "Revertido: {name}" },
"Changelist #{n} reverted by a new changelist.": { ru: "Changelist #{n} откачен новым changelist'ом.", de: "Changelist #{n} durch eine neue Changelist zurückgesetzt.", fr: "Changelist #{n} annulé par un nouveau changelist.", es: "Changelist #{n} revertido por un nuevo changelist." },
"Path not set": { ru: "Путь не определён", de: "Pfad nicht festgelegt", fr: "Chemin non défini", es: "Ruta no definida" },
"{what} copied": { ru: "{what} скопировано", de: "{what} kopiert", fr: "{what} copié", es: "{what} copiado" },
"Copy failed": { ru: "Не удалось скопировать", de: "Kopieren fehlgeschlagen", fr: "Échec de la copie", es: "Error al copiar" },
"Workspace name": { ru: "Имя workspace", de: "Workspace-Name", fr: "Nom du workspace", es: "Nombre del workspace" },
"Path": { ru: "Путь", de: "Pfad", fr: "Chemin", es: "Ruta" },
"Number": { ru: "Номер", de: "Nummer", fr: "Numéro", es: "Número" },
"Description": { ru: "Описание", de: "Beschreibung", fr: "Description", es: "Descripción" },
"Found and added to Changes: {n}": { ru: "Найдено и добавлено в Changes: {n}", de: "Gefunden und zu Änderungen hinzugefügt: {n}", fr: "Trouvé et ajouté aux modifications : {n}", es: "Encontrado y añadido a Cambios: {n}" },
"No new or changed files found.": { ru: "Новых или изменённых файлов не найдено.", de: "Keine neuen oder geänderten Dateien gefunden.", fr: "Aucun fichier nouveau ou modifié trouvé.", es: "No se encontraron archivos nuevos o modificados." },
// drop zone
"Drop to add to Perforce": { ru: "Отпусти, чтобы добавить в Perforce", de: "Loslassen, um zu Perforce hinzuzufügen", fr: "Relâchez pour ajouter à Perforce", es: "Suelta para añadir a Perforce" },
// app modal — workspaces
"Choose workspace": { ru: "Выбор workspace", de: "Workspace wählen", fr: "Choisir un workspace", es: "Elegir workspace" },
"No workspaces available": { ru: "Нет доступных workspace", de: "Keine Workspaces verfügbar", fr: "Aucun workspace disponible", es: "No hay workspaces disponibles" },
"Active: {c}": { ru: "Активный: {c}", de: "Aktiv: {c}", fr: "Actif : {c}", es: "Activo: {c}" },
// app modal — scope
".. up": { ru: ".. вверх", de: ".. hoch", fr: ".. haut", es: ".. arriba" },
"No subfolders": { ru: "Вложенных папок нет", de: "Keine Unterordner", fr: "Aucun sous-dossier", es: "Sin subcarpetas" },
"Work in: {path}": { ru: "Работать в: {path}", de: "Arbeiten in: {path}", fr: "Travailler dans : {path}", es: "Trabajar en: {path}" },
"Work in the whole workspace (//…)": { ru: "Работать во всём workspace (//…)", de: "Im ganzen Workspace arbeiten (//…)", fr: "Travailler dans tout le workspace (//…)", es: "Trabajar en todo el workspace (//…)" },
// app modal — settings
"Settings": { ru: "Настройки", de: "Einstellungen", fr: "Paramètres", es: "Ajustes" },
"Language": { ru: "Язык", de: "Sprache", fr: "Langue", es: "Idioma" },
"Light ☀": { ru: "Светлая ☀", de: "Hell ☀", fr: "Clair ☀", es: "Claro ☀" },
"Dark ☾": { ru: "Тёмная ☾", de: "Dunkel ☾", fr: "Sombre ☾", es: "Oscuro ☾" },
"Server version": { ru: "Версия сервера", de: "Serverversion", fr: "Version du serveur", es: "Versión del servidor" },
// about
"Exbyte Depot — native Perforce client": { ru: "Exbyte Depot — нативный клиент Perforce", de: "Exbyte Depot — nativer Perforce-Client", fr: "Exbyte Depot — client Perforce natif", es: "Exbyte Depot — cliente nativo de Perforce" },
"Built by Exbyte Studios": { ru: "Собственная разработка Exbyte Studios", de: "Entwickelt von Exbyte Studios", fr: "Développé par Exbyte Studios", es: "Desarrollado por Exbyte Studios" },
// change detail
"Select a changelist in History": { ru: "Выберите changelist в History", de: "Wähle eine Changelist im Verlauf", fr: "Sélectionnez un changelist dans lhistorique", es: "Selecciona un changelist en el historial" },
"(no description)": { ru: "(без описания)", de: "(keine Beschreibung)", fr: "(sans description)", es: "(sin descripción)" },
"{user} · {time} · {n} file(s)": { ru: "{user} · {time} · {n} файл(ов)", de: "{user} · {time} · {n} Datei(en)", fr: "{user} · {time} · {n} fichier(s)", es: "{user} · {time} · {n} archivo(s)" },
"Loading file list…": { ru: "Загрузка списка файлов…", de: "Dateiliste wird geladen…", fr: "Chargement de la liste des fichiers…", es: "Cargando lista de archivos…" },
// history list
"Loading history…": { ru: "Загрузка истории…", de: "Verlauf wird geladen…", fr: "Chargement de lhistorique…", es: "Cargando historial…" },
"No submitted changes.": { ru: "Нет засабмиченных изменений.", de: "Keine übermittelten Änderungen.", fr: "Aucune modification envoyée.", es: "No hay cambios enviados." },
// history context menu
"Open this changelist": { ru: "Открыть этот changelist", de: "Diese Changelist öffnen", fr: "Ouvrir ce changelist", es: "Abrir este changelist" },
"Revert (undo) #{n}": { ru: "Откатить (revert) #{n}", de: "Zurücksetzen (undo) #{n}", fr: "Annuler (revert) #{n}", es: "Revertir (undo) #{n}" },
"Copy number #{n}": { ru: "Скопировать номер #{n}", de: "Nummer #{n} kopieren", fr: "Copier le numéro #{n}", es: "Copiar número #{n}" },
"Copy description": { ru: "Скопировать описание", de: "Beschreibung kopieren", fr: "Copier la description", es: "Copiar descripción" },
// preview
"Select a file on the left": { ru: "Выберите файл слева", de: "Wähle links eine Datei", fr: "Sélectionnez un fichier à gauche", es: "Selecciona un archivo a la izquierda" },
"Loading diff…": { ru: "Загрузка diff…", de: "Diff wird geladen…", fr: "Chargement du diff…", es: "Cargando diff…" },
"No text differences (or binary file).": { ru: "Нет текстовых отличий (или бинарный файл).", de: "Keine Textunterschiede (oder Binärdatei).", fr: "Aucune différence de texte (ou fichier binaire).", es: "Sin diferencias de texto (o archivo binario)." },
"Image": { ru: "Изображение", de: "Bild", fr: "Image", es: "Imagen" },
"Resolution": { ru: "Разрешение", de: "Auflösung", fr: "Résolution", es: "Resolución" },
"Format": { ru: "Формат", de: "Format", fr: "Format", es: "Formato" },
"Source": { ru: "Источник", de: "Quelle", fr: "Source", es: "Fuente" },
"working copy": { ru: "рабочая копия", de: "Arbeitskopie", fr: "copie de travail", es: "copia de trabajo" },
"Unreal · embedded thumbnail": { ru: "Unreal · вшитая миниатюра", de: "Unreal · eingebettetes Vorschaubild", fr: "Unreal · miniature intégrée", es: "Unreal · miniatura incrustada" },
"Reading asset…": { ru: "Читаю ассет…", de: "Asset wird gelesen…", fr: "Lecture de lasset…", es: "Leyendo asset…" },
"This asset has no embedded thumbnail. Interactive 3D works for FBX / OBJ / GLB / GLTF / STL — export the model to one of these formats.": { ru: "В этом ассете нет вшитой миниатюры. Интерактивный 3D работает для FBX / OBJ / GLB / GLTF / STL — экспортни модель в один из этих форматов.", de: "Dieses Asset hat kein eingebettetes Vorschaubild. Interaktives 3D funktioniert für FBX / OBJ / GLB / GLTF / STL — exportiere das Modell in eines dieser Formate.", fr: "Cet asset na pas de miniature intégrée. La 3D interactive fonctionne pour FBX / OBJ / GLB / GLTF / STL — exportez le modèle dans lun de ces formats.", es: "Este asset no tiene miniatura incrustada. El 3D interactivo funciona con FBX / OBJ / GLB / GLTF / STL — exporta el modelo a uno de estos formatos." },
"Type": { ru: "Тип", de: "Typ", fr: "Type", es: "Tipo" },
"Preview": { ru: "Превью", de: "Vorschau", fr: "Aperçu", es: "Vista previa" },
"thumbnail": { ru: "миниатюра", de: "Vorschaubild", fr: "miniature", es: "miniatura" },
"none": { ru: "нет", de: "keine", fr: "aucune", es: "ninguna" },
"Revision": { ru: "Ревизия", de: "Revision", fr: "Révision", es: "Revisión" },
// model viewer
"drag — rotate · wheel — zoom · right-click — pan": { ru: "тащи — вращать · колесо — зум · ПКМ — панорама", de: "ziehen — drehen · Rad — zoomen · Rechtsklick — schwenken", fr: "glisser — pivoter · molette — zoom · clic droit — panoramique", es: "arrastra — girar · rueda — zoom · clic derecho — panorámica" },
"Loading model…": { ru: "Загрузка модели…", de: "Modell wird geladen…", fr: "Chargement du modèle…", es: "Cargando modelo…" },
"Format not supported by the viewer": { ru: "Формат не поддерживается вьювером", de: "Format vom Viewer nicht unterstützt", fr: "Format non pris en charge par la visionneuse", es: "Formato no compatible con el visor" },
"Collada: empty scene": { ru: "Collada: пустая сцена", de: "Collada: leere Szene", fr: "Collada : scène vide", es: "Collada: escena vacía" },
"Triangles": { ru: "Треугольники", de: "Dreiecke", fr: "Triangles", es: "Triángulos" },
"Vertices": { ru: "Вершины", de: "Ecken", fr: "Sommets", es: "Vértices" },
"Meshes · Materials": { ru: "Меши · Материалы", de: "Meshes · Materialien", fr: "Maillages · Matériaux", es: "Mallas · Materiales" },
"Render": { ru: "Рендер", de: "Render", fr: "Rendu", es: "Render" },
"Wireframe": { ru: "Каркас", de: "Drahtgitter", fr: "Filaire", es: "Malla" },
"Grid": { ru: "Сетка", de: "Raster", fr: "Grille", es: "Cuadrícula" },
"Reset view": { ru: "Сбросить вид", de: "Ansicht zurücksetzen", fr: "Réinitialiser la vue", es: "Restablecer vista" },
// updater
"Update available": { ru: "Доступно обновление", de: "Update verfügbar", fr: "Mise à jour disponible", es: "Actualización disponible" },
"Later": { ru: "Позже", de: "Später", fr: "Plus tard", es: "Más tarde" },
"Downloading… {pct}%": { ru: "Загрузка… {pct}%", de: "Wird geladen… {pct}%", fr: "Téléchargement… {pct}%", es: "Descargando… {pct}%" },
"Installed — restarting…": { ru: "Установлено — перезапускаю…", de: "Installiert — Neustart…", fr: "Installé — redémarrage…", es: "Instalado — reiniciando…" },
"Update now": { ru: "Обновить сейчас", de: "Jetzt aktualisieren", fr: "Mettre à jour", es: "Actualizar ahora" },
};
export function t(en: string, vars?: Record<string, string | number>): string {
let s = D[en]?.[LANG] ?? en;
if (vars) for (const k in vars) s = s.split("{" + k + "}").join(String(vars[k]));
return s;
}