From 46a3be44893227f765e054daf66f2d83f98ce114 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Tue, 7 Jul 2026 14:10:35 +0300 Subject: [PATCH] Internationalize UI (EN default) + 5-language switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/src/lib.rs | 34 +++--- src/App.css | 13 ++- src/App.tsx | 255 +++++++++++++++++++++++-------------------- src/ModelViewer.tsx | 23 ++-- src/Updater.tsx | 13 ++- src/i18n.ts | 220 +++++++++++++++++++++++++++++++++++++ 6 files changed, 402 insertions(+), 156 deletions(-) create mode 100644 src/i18n.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fb82cd0..7b19d1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,7 +46,7 @@ fn run_json(conn: &Conn, args: &[&str]) -> Result, 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) -> Result { 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 { 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 { async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result { 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 { .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 { 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 { 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 { 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 Result { 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, 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() diff --git a/src/App.css b/src/App.css index 5143962..b14ab67 100644 --- a/src/App.css +++ b/src/App.css @@ -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} diff --git a/src/App.tsx b/src/App.tsx index ea240d4..2e3dd4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 (
- - - + + +
); } @@ -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); + return [lang, (l) => { setLangGlobal(l); setLang(l); }]; +} + /* ============================================================= */ export default function App() { const [phase, setPhase] = useState<"loading" | "connect" | "main">("loading"); const [info, setInfo] = useState(null); const [session, setSession] = useState(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 = { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />; else - content = { 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
{I.hex}
Exbyte Depot — Perforce
- +
{I.cube}
-

Connect to Perforce

-
Exbyte Depot — sign in to your P4 server
+

{t("Connect to Perforce")}

+
{t("Exbyte Depot — sign in to your P4 server")}
-
+
{I.monitor} setServer(e.target.value)} />
-
+
setUser(e.target.value)} onBlur={loadClients} />
-
+
setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} />
-
+
{clients.length ? ( @@ -148,13 +156,13 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg {clients.map((c) => )} ) : ( - setClient(e.target.value)} /> + setClient(e.target.value)} /> )}
{err &&
{err}
}
@@ -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("changes"); const [modal, setModal] = useState(null); const [activePath, setActivePath] = useState(() => 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\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\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 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
e.stopPropagation()}> {Object.entries(menus).map(([name, items]) => (
setOpenMenu(openMenu === name ? null : name)}> - {name} + {t(name)}
{items.map((it) => (
it.act?.()}> @@ -479,85 +487,85 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis ))}
{info?.serverAddress || "connected"}
- +
-
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")) }, ])}> {I.monitor} - Workspace{info?.clientName || "—"} + {t("Workspace")}{info?.clientName || "—"} {I.chev}
-
startResize(e, "sync")} title="Тянуть — изменить ширину" /> -
browseTo("")} title="Выбрать рабочую папку проекта · ПКМ — ещё" +
startResize(e, "sync")} title={t("Drag to resize")} /> +
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")) }, ])}> {I.branch} - Рабочая папка{activePath || "весь workspace (//…)"} + {t("Working folder")}{activePath || t("whole workspace (//…)")} {I.chev}
{pending.length > 0 ? ( -
+
- Submit на сервер{pending.length} changelist(ов) → push + {t("Submit to server")}{t("{n} changelist(s) → push", { n: pending.length })}
) : (
{I.sync} - Get Latest{busy ? "Работаю…" : "забрать с сервера (pull)"} + {t("Get Latest")}{busy ? t("Working…") : t("pull from server")}
)}
-
startResize(e, "left")} title="Тянуть — изменить ширину" /> +
startResize(e, "left")} title={t("Drag to resize")} />
- - + +
{tab === "changes" ? ( <>
-
{I.search} setFilter(e.target.value)} />
+
{I.search} setFilter(e.target.value)} />
{pending.length > 0 && ( -
+
- {pending.length} changelist(ов) закоммичено — готово к Submit + {t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}
)} {scanning && (
- Ищу изменения на диске…{!activePath && " для всего workspace это долго — выбери рабочую папку проекта сверху"} + {t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}
)}
- {allChecked ? I.check : null} - {checked.size} из {view.length} выбрано -
{view.length === 0 ? (
- {scanning ? <>Ищу изменения на диске… : <> + {scanning ? <>{t("Scanning disk for changes…")} : <> - {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”.")} }
) : ( @@ -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; })} /> )}
- setDesc(e.target.value)} + setDesc(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} /> -