From ec8f31c6548bba275502ac6a7d093030d7ef28af Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 8 Jul 2026 12:46:37 +0300 Subject: [PATCH] Offline Work mode + Reconcile Offline Work dialog, menu tooltips, spinner fix; v0.3.0 - Explicit Work Offline toggle (P4V-style): stops server polling, calm offline banner, persisted - Reconcile Offline Work dialog: preview adds/edits/deletes with per-file checkboxes, opens picked into a changelist - Make writable (offline edit) context action; backend p4_reconcile_preview/apply + p4_set_writable - Tooltips (title) on every top-menu item, 5 languages - Fix .ldr spinner rendering (inline-block + box-sizing) so it's a clean circle everywhere Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 70 ++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/App.css | 13 ++- src/App.tsx | 167 ++++++++++++++++++++++++++++++++++---- src/i18n.ts | 53 ++++++++++++ src/p4.ts | 3 + 8 files changed, 294 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index d38df1b..7159c36 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.2.4", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eb5d2ae..26bbb8c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exbyte-depot" -version = "0.2.4" +version = "0.3.0" description = "Exbyte Depot — native Perforce client by Exbyte Studios" authors = ["Exbyte Studios"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fed1dac..8863d8c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1943,6 +1943,73 @@ async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result, scope: String) -> Result, String> { + let conn = current(&state)?; + run_json(&conn, &["reconcile", "-n", "-e", "-a", "-d", &scope_spec(&scope)]).or_else(|e| { + let l = e.to_lowercase(); + if l.contains("no file") || l.contains("up-to-date") || l.contains("no such") || l.contains("no differing") || l.contains("- no ") { + Ok(Vec::new()) + } else { + Err(e) + } + }) +} + +/// Apply the offline reconcile for the picked files only (`p4 reconcile -e -a -d +/// file…`): opens the selected edits/adds/deletes into the pending changelist so +/// they can be reviewed and submitted. Paths are the depot paths from the preview. +#[tauri::command] +async fn p4_reconcile_apply(state: State<'_, AppState>, paths: Vec) -> Result, String> { + let conn = current(&state)?; + let clean: Vec = paths.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect(); + if clean.is_empty() { + return Err("No files selected".into()); + } + let mut args: Vec<&str> = vec!["reconcile", "-e", "-a", "-d"]; + for p in &clean { + args.push(p.as_str()); + } + run_json(&conn, &args).or_else(|e| { + let l = e.to_lowercase(); + if l.contains("no file") || l.contains("up-to-date") || l.contains("no differing") { + Ok(Vec::new()) + } else { + Err(e) + } + }) +} + +/// Toggle the read-only attribute on local working files so they can be edited +/// while offline (Perforce syncs files read-only; clearing the flag lets an +/// editor save into them before a checkout is possible). Confined to the +/// workspace root. Reports how many files were changed. +#[tauri::command] +async fn p4_set_writable(state: State<'_, AppState>, paths: Vec, writable: bool) -> Result { + let conn = current(&state)?; + let mut done = 0usize; + for p in paths.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) { + ensure_under_root(&conn, p)?; + let meta = std::fs::metadata(p).map_err(|e| format!("File not found: {e}"))?; + let mut perms = meta.permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(!writable); + std::fs::set_permissions(p, perms).map_err(|e| format!("Cannot change {p}: {e}"))?; + done += 1; + } + Ok(if writable { + format!("{done} file(s) made writable") + } else { + format!("{done} file(s) set read-only") + }) +} + /// Full revision history of a single file (`p4 filelog -l`), including /// per-revision changelist, action, user, time and description. Returns the /// tagged object with rev0/change0/action0/… arrays. @@ -2301,6 +2368,9 @@ pub fn run() { p4_filelog, p4_clean_preview, p4_clean_apply, + p4_reconcile_preview, + p4_reconcile_apply, + p4_set_writable, p4_submit_shelved, p4_submit_opts, p4_ignore_read, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index df0472c..ab8274d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Exbyte Depot", "mainBinaryName": "Exbyte Depot", - "version": "0.2.4", + "version": "0.3.0", "identifier": "com.bonchellon.exbyte-depot", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index d918265..a0caa56 100644 --- a/src/App.css +++ b/src/App.css @@ -560,7 +560,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none} display:flex;align-items:center;justify-content:center;gap:9px} .connect:disabled{opacity:.6;cursor:not-allowed}.connect:not(:disabled):hover{filter:brightness(1.08)}.connect svg{width:16px;height:16px} .spin{animation:spin 1s linear infinite} -.ldr{width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite} +.ldr{display:inline-block;box-sizing:border-box;vertical-align:middle;width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:0 0 auto} /* diff view */ .diff{flex:1;overflow:auto;min-height:0;font-family:var(--mono);font-size:12.5px;line-height:1.85;padding:6px 0} @@ -768,8 +768,19 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .netbar.off{background:rgba(242,99,126,.14);color:var(--del);border-bottom:1px solid rgba(242,99,126,.3)} .netbar.warn{background:rgba(232,176,75,.14);color:var(--edit);border-bottom:1px solid rgba(232,176,75,.3);cursor:pointer} .netbar.warn:hover{background:rgba(232,176,75,.2)} +/* explicit "Work Offline" mode — violet, calmer than the error-red disconnect bar */ +.netbar.offmode{background:rgba(124,110,246,.13);color:var(--accent-2);border-bottom:1px solid rgba(124,110,246,.3)} .netbar-btn{margin-left:auto;border:1px solid currentColor;background:none;color:inherit;font-size:11.5px;font-weight:700; border-radius:7px;padding:4px 12px;cursor:pointer;font-family:var(--font)} +.netbar-btn+.netbar-btn{margin-left:0} +.netbar-btn:hover{background:currentColor} +.netbar-btn:hover{color:var(--bg)} +/* reconcile offline-work dialog */ +.rec-summary{display:flex;align-items:center;gap:6px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--border-soft)} +.rec-all{margin-left:auto;color:var(--accent-2);font-weight:700;cursor:pointer} +.rec-all:hover{text-decoration:underline} +.rec-row{cursor:pointer} +.rec-row .chk{flex:0 0 16px} /* "held by someone else" pill on a Changes row */ .held{display:flex;align-items:center;gap:4px;flex:0 0 auto;font-size:10.5px;font-weight:600;color:var(--muted); background:var(--chip);border:1px solid var(--border-soft);border-radius:7px;padding:2px 7px;max-width:120px;overflow:hidden} diff --git a/src/App.tsx b/src/App.tsx index b0723d0..bfc9152 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -315,6 +315,7 @@ type ModalState = | { kind: "streams" } | { kind: "jobs" } | { kind: "clean" } + | { kind: "reconcile" } | { kind: "ignore" } | { kind: "filelog"; depot: string; name: string } | { kind: "client"; name: string; isNew: boolean } @@ -366,6 +367,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [needResolve, setNeedResolve] = useState([]); // files awaiting conflict resolution const [resolveOpen, setResolveOpen] = useState(false); const [offline, setOffline] = useState(false); // lost connection to the server + const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode const lastSubmit = useRef(""); // newest submitted CL seen (new-submit toast) const notifPrimed = useRef(false); // don't toast on the very first poll const [buildLog, setBuildLog] = useState([]); // live MSBuild output @@ -644,6 +646,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set useEffect(() => { let alive = true; const tick = async () => { + if (workOffline) return; // explicit offline-work mode: don't poll the server try { const latest = await p4.latestChange(activePath); if (!alive) return; @@ -668,7 +671,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set tick(); return () => { alive = false; clearInterval(id); }; // eslint-disable-next-line - }, [activePath, offline, tab]); + }, [activePath, offline, tab, workOffline]); // drag & drop files/folders onto the window → reconcile them into the changelist useEffect(() => { @@ -1041,6 +1044,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set { label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) }, { label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) }, { label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) }, + ...(workOffline ? [{ label: t("Make writable (offline edit)"), icon: I.unlock, act: () => makeWritable(targets) }] : []), ...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })), ...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []), ...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []), @@ -1064,6 +1068,25 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set try { await p4.reopenTo(change, dps); flash(t("Moved {n} file(s) to #{c}.", { n: dps.length, c: change })); await refresh(); } catch (e) { flash(String(e), true); } } + // toggle explicit "Work Offline" mode (P4V-style): stop polling the server; + // edits stay local until reconciled. + function toggleWorkOffline() { + setWorkOffline((v) => { + const nv = !v; + try { localStorage.setItem("exd-workoffline", nv ? "1" : "0"); } catch {} + if (nv) { setOffline(false); flash(t("Working offline — edits stay local. Run Reconcile Offline Work to sync.")); } + else { flash(t("Back online.")); refresh(); } + return nv; + }); + } + // clear the read-only bit on selected local files so they can be edited while + // offline (before a checkout). Reconcile picks the changes up later. + async function makeWritable(targets: OpenedFile[]) { + const locals = targets.map((f) => f.clientFile || "").filter(Boolean) as string[]; + if (!locals.length) { flash(t("No local path for the selection."), true); return; } + try { await p4.setWritable(locals, true); flash(t("{n} file(s) made writable for offline editing.", { n: locals.length })); } + catch (e) { flash(String(e), true); } + } // diff a historical revision against the one before it (History preview) async function showDiffPrev(file: OpenedFile) { const dp = file.depotFile || ""; @@ -1086,18 +1109,50 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set } catch (e) { flash(String(e), true); } } - const menus: Record void }[]> = { - File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("New workspace…"), icon: I.monitor, act: newWorkspace }, ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }], - Connection: [{ label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }], - Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Clean workspace…"), icon: I.revert, act: () => setModal({ kind: "clean" }) }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, act: getLatest }, { label: t("Get Revision…"), icon: I.clock, act: promptSyncTo }, { label: t("Commit (local)"), icon: I.check, act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), icon: I.revert, act: revertAll }, { label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }], - Window: [ - { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, act: () => openDock("log") }, - { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, act: () => openDock("terminal") }, - ...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, act: () => openDock("unreal") }] : []), - { label: t("File Locks…"), icon: I.lock, act: () => setModal({ kind: "locks" }) }, + const menus: Record void }[]> = { + File: [ + { label: t("Switch workspace…"), icon: I.monitor, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces }, + { label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace }, + ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, hint: t("Edit the current workspace spec — root folder and depot view mapping."), act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), + { label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") }, + { label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect }, ], - Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: t("Streams…"), icon: I.branch, act: () => setModal({ kind: "streams" }) }, { label: t("Jobs…"), icon: I.log, act: () => setModal({ kind: "jobs" }) }, { label: t("Edit .p4ignore…"), icon: I.gear, act: () => setModal({ kind: "ignore" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }], - Help: [{ label: t("About"), icon: I.info, act: () => setModal({ kind: "about" }) }], + Connection: [ + { label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() }, + { label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") }, + ], + Actions: [ + { label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, hint: t("Scan the workspace for files changed outside the app (reconcile)."), act: rescan }, + { label: (workOffline ? "✓ " : "") + t("Work Offline"), icon: I.power, hint: t("Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V."), act: toggleWorkOffline }, + { label: t("Reconcile Offline Work…"), icon: I.scan, hint: t("Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them."), act: () => setModal({ kind: "reconcile" }) }, + { label: t("Clean workspace…"), icon: I.revert, hint: t("Make the workspace exactly match the depot — discards un-opened local changes."), act: () => setModal({ kind: "clean" }) }, + { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, hint: t("Sync the workspace to the newest revision (pull)."), act: getLatest }, + { label: t("Get Revision…"), icon: I.clock, hint: t("Sync the workspace to a specific changelist, label or date."), act: promptSyncTo }, + { label: t("Commit (local)"), icon: I.check, hint: t("Save selected files into a local pending changelist (not sent yet)."), act: doCommit }, + { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, hint: t("Send pending changelists to the server (push)."), act: pushAll }, + ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, hint: t("Resolve merge conflicts left after a sync."), act: () => setResolveOpen(true) }] : []), + { label: t("Revert All…"), icon: I.revert, hint: t("Discard every open change and restore depot versions."), act: revertAll }, + { label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() }, + ], + Window: [ + { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") }, + { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") }, + ...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, hint: t("Show output from the headless Unreal preview process."), act: () => openDock("unreal") }] : []), + { label: t("File Locks…"), icon: I.lock, hint: t("See every file currently exclusively locked and by whom."), act: () => setModal({ kind: "locks" }) }, + ], + Tools: [ + { label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) }, + { label: t("Exclusive Locks (typemap)…"), icon: I.lock, hint: t("Set which binary asset types are exclusive-checkout (+l) so only one person edits them."), act: () => setModal({ kind: "typemap" }) }, + { label: t("Labels…"), icon: I.clock, hint: t("Named snapshots of file revisions: tag files, or sync to a label."), act: () => setModal({ kind: "labels" }) }, + { label: t("Integrate / Merge / Copy…"), icon: I.branch, hint: t("Move changes between branches of the depot."), act: () => setModal({ kind: "branch" }) }, + { label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) }, + { label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) }, + { label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) }, + { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the Visual Studio solution found in the working folder."), act: startBuild }, + { label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) }, + { label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) }, + ], + Help: [{ label: t("About"), icon: I.info, hint: t("Version and information about Exbyte Depot."), act: () => setModal({ kind: "about" }) }], }; return ( @@ -1110,7 +1165,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {t(name)}
{items.map((it) => ( -
it.act?.()}> +
it.act?.()} title={it.hint}> {it.icon && {it.icon}} {it.label}{it.kb && {it.kb}}
@@ -1164,9 +1219,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
- {offline && ( + {workOffline ? ( +
+ {I.power}{t("Working offline — edits stay on your disk. Reconcile to bring them into a changelist.")} + + +
+ ) : offline && (
{t("Disconnected from the server — retrying…")} +
)} {needResolve.length > 0 && tab === "changes" && ( @@ -1359,6 +1421,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {modal?.kind === "streams" && setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />} {modal?.kind === "jobs" && setModal(null)} onFlash={flash} />} {modal?.kind === "clean" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} + {modal?.kind === "reconcile" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} {modal?.kind === "ignore" && setModal(null)} onFlash={flash} />} {modal?.kind === "filelog" && setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />} {modal?.kind === "client" && setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />} @@ -2131,6 +2194,82 @@ function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClos ); } +/* ---------------- reconcile offline work ---------------- */ +// P4V-style "Reconcile Offline Work": scan the workspace for changes made while +// disconnected (edits / adds / deletes) and open the picked ones into a changelist. +function ReconcileModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) { + const [files, setFiles] = useState([]); + const [busy, setBusy] = useState(true); + const [applying, setApplying] = useState(false); + const [sel, setSel] = useState>(new Set()); + const keyOf = (f: OpenedFile) => f.depotFile || f.clientFile || ""; + useEffect(() => { + let live = true; + p4.reconcilePreview(scope) + .then((f) => { if (live) { setFiles(f); setSel(new Set(f.map(keyOf).filter(Boolean))); } }) + .catch((e) => { if (live) { onFlash(String(e), true); setFiles([]); } }) + .finally(() => live && setBusy(false)); + const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", k); + return () => { live = false; document.removeEventListener("keydown", k); }; + }, [scope]); // eslint-disable-line + const toggle = (k: string) => setSel((s) => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n; }); + const allOn = files.length > 0 && sel.size === files.length; + const toggleAll = () => setSel(allOn ? new Set() : new Set(files.map(keyOf).filter(Boolean))); + async function apply() { + const paths = files.map(keyOf).filter((k) => sel.has(k)); + if (!paths.length) return; + setApplying(true); + try { + const opened = await p4.reconcileApply(paths); + onFlash(t("Reconciled {n} file(s) into a changelist.", { n: opened.length || paths.length })); + onDone(); + } catch (e) { onFlash(String(e), true); } + finally { setApplying(false); } + } + // group for a readable header count + const counts = { add: 0, edit: 0, delete: 0 } as Record; + for (const f of files) { const a = (f.action || "").toLowerCase(); if (a.includes("add")) counts.add++; else if (a.includes("delete")) counts.delete++; else counts.edit++; } + return ( +
+
e.stopPropagation()}> +
{I.scan}

{t("Reconcile Offline Work")}

{scope || "//…"} · {t("{n} changes", { n: files.length })} + +
+
{t("These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.")}
+ {!busy && files.length > 0 && ( +
+ +{counts.add} {t("added")} · ±{counts.edit} {t("modified")} · −{counts.delete} {t("deleted")} + {allOn ? t("Deselect all") : t("Select all")} +
+ )} +
+ {busy &&
{t("Scanning workspace for offline changes…")}
} + {!busy && files.length === 0 &&
{t("Nothing to reconcile — the workspace matches what the server knows.")}
} + {files.map((f) => { + const dp = keyOf(f); + const sp = splitPath(dp); + const st = statusOf(f.action); + const on = sel.has(dp); + return ( +
toggle(dp)}> + {on ? I.check : null} + {st.ch} + {sp.name}{sp.dir} + {f.action} +
+ ); + })} +
+
+ + +
+
+
+ ); +} + /* ---------------- file history timeline (filelog) ---------------- */ function FilelogModal({ depot, name, onClose, onFlash, onShowDiff }: { depot: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onShowDiff: (title: string, text: string) => void }) { const [revs, setRevs] = useState([]); diff --git a/src/i18n.ts b/src/i18n.ts index e681973..bf39c7a 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -489,6 +489,59 @@ const D: Record = { ".p4ignore saved.": { ru: ".p4ignore сохранён.", de: ".p4ignore gespeichert.", fr: ".p4ignore enregistré.", es: ".p4ignore guardado." }, "Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." }, "Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" }, + // ---- offline work + reconcile ---- + "Work Offline": { ru: "Работать офлайн", de: "Offline arbeiten", fr: "Travailler hors ligne", es: "Trabajar sin conexión" }, + "Reconcile Offline Work…": { ru: "Согласовать офлайн-правки…", de: "Offline-Arbeit abgleichen…", fr: "Réconcilier le travail hors ligne…", es: "Reconciliar trabajo sin conexión…" }, + "Reconcile Offline Work": { ru: "Согласование офлайн-правок", de: "Offline-Arbeit abgleichen", fr: "Réconcilier le travail hors ligne", es: "Reconciliar trabajo sin conexión" }, + "Working offline — edits stay local. Run Reconcile Offline Work to sync.": { ru: "Работаешь офлайн — правки остаются локально. Запусти «Согласовать офлайн-правки», чтобы синхронизировать.", de: "Offline-Modus — Änderungen bleiben lokal. Führe „Offline-Arbeit abgleichen“ aus, um zu synchronisieren.", fr: "Hors ligne — les modifications restent locales. Lance « Réconcilier le travail hors ligne » pour synchroniser.", es: "Sin conexión — los cambios quedan en local. Ejecuta «Reconciliar trabajo sin conexión» para sincronizar." }, + "Back online.": { ru: "Снова онлайн.", de: "Wieder online.", fr: "De nouveau en ligne.", es: "De nuevo en línea." }, + "Working offline — edits stay on your disk. Reconcile to bring them into a changelist.": { ru: "Работаешь офлайн — правки лежат на диске. Согласуй, чтобы собрать их в changelist.", de: "Offline — Änderungen liegen auf deiner Festplatte. Gleiche ab, um sie in eine Changelist zu übernehmen.", fr: "Hors ligne — les modifications sont sur votre disque. Réconciliez pour les regrouper dans une changelist.", es: "Sin conexión — los cambios están en tu disco. Reconcilia para llevarlos a una changelist." }, + "Reconcile…": { ru: "Согласовать…", de: "Abgleichen…", fr: "Réconcilier…", es: "Reconciliar…" }, + "Go online": { ru: "Выйти онлайн", de: "Online gehen", fr: "Passer en ligne", es: "Conectar" }, + "Work offline instead": { ru: "Перейти в офлайн", de: "Stattdessen offline arbeiten", fr: "Travailler hors ligne", es: "Trabajar sin conexión" }, + "Make writable (offline edit)": { ru: "Сделать редактируемым (офлайн)", de: "Beschreibbar machen (offline)", fr: "Rendre modifiable (hors ligne)", es: "Hacer editable (sin conexión)" }, + "No local path for the selection.": { ru: "Нет локального пути для выделения.", de: "Kein lokaler Pfad für die Auswahl.", fr: "Aucun chemin local pour la sélection.", es: "No hay ruta local para la selección." }, + "{n} file(s) made writable for offline editing.": { ru: "{n} файл(ов) сделаны редактируемыми для офлайн-правки.", de: "{n} Datei(en) für Offline-Bearbeitung beschreibbar gemacht.", fr: "{n} fichier(s) rendus modifiables pour l'édition hors ligne.", es: "{n} archivo(s) hechos editables para edición sin conexión." }, + "These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.": { ru: "Эти файлы изменились на диске без ведома сервера. Выбери, что внести в pending changelist: добавленные → p4 add, изменённые → p4 edit, пропавшие → p4 delete. Ничего не сабмитится — сначала проверишь и отправишь сам.", de: "Diese Dateien haben sich auf der Festplatte geändert, ohne dass der Server es weiß. Wähle, was in eine offene Changelist soll: neue → p4 add, geänderte → p4 edit, fehlende → p4 delete. Nichts wird übermittelt — du prüfst und übermittelst danach.", fr: "Ces fichiers ont changé sur le disque à l'insu du serveur. Choisis ceux à placer dans une changelist en attente : ajoutés → p4 add, modifiés → p4 edit, manquants → p4 delete. Rien n'est soumis — tu vérifies et soumets ensuite.", es: "Estos archivos cambiaron en disco sin que el servidor lo supiera. Elige cuáles llevar a una changelist pendiente: añadidos → p4 add, modificados → p4 edit, faltantes → p4 delete. Nada se envía — revisas y envías después." }, + "added": { ru: "добавлено", de: "hinzugefügt", fr: "ajoutés", es: "añadidos" }, + "modified": { ru: "изменено", de: "geändert", fr: "modifiés", es: "modificados" }, + "deleted": { ru: "удалено", de: "gelöscht", fr: "supprimés", es: "eliminados" }, + "Scanning workspace for offline changes…": { ru: "Сканирую воркспейс на офлайн-изменения…", de: "Arbeitsbereich wird auf Offline-Änderungen geprüft…", fr: "Analyse de l'espace de travail pour les modifications hors ligne…", es: "Analizando el espacio de trabajo en busca de cambios sin conexión…" }, + "Nothing to reconcile — the workspace matches what the server knows.": { ru: "Согласовывать нечего — воркспейс совпадает с тем, что знает сервер.", de: "Nichts abzugleichen — der Arbeitsbereich stimmt mit dem Server überein.", fr: "Rien à réconcilier — l'espace de travail correspond à ce que le serveur connaît.", es: "Nada que reconciliar — el espacio de trabajo coincide con lo que el servidor conoce." }, + "Reconcile {n} file(s)": { ru: "Согласовать {n} файл(ов)", de: "{n} Datei(en) abgleichen", fr: "Réconcilier {n} fichier(s)", es: "Reconciliar {n} archivo(s)" }, + "Reconciled {n} file(s) into a changelist.": { ru: "Согласовано {n} файл(ов) в changelist.", de: "{n} Datei(en) in eine Changelist abgeglichen.", fr: "{n} fichier(s) réconciliés dans une changelist.", es: "{n} archivo(s) reconciliados en una changelist." }, + // ---- menu tooltips ---- + "Change which Perforce workspace (client) you're working in.": { ru: "Сменить воркспейс (client), в котором ты работаешь.", de: "Wechsle den Perforce-Arbeitsbereich (Client), in dem du arbeitest.", fr: "Change l'espace de travail Perforce (client) dans lequel tu travailles.", es: "Cambia el espacio de trabajo de Perforce (client) en el que trabajas." }, + "Create a new workspace mapping depot folders to a local folder.": { ru: "Создать новый воркспейс, сопоставив папки депо с локальной папкой.", de: "Erstelle einen neuen Arbeitsbereich, der Depot-Ordner einem lokalen Ordner zuordnet.", fr: "Crée un espace de travail associant des dossiers du depot à un dossier local.", es: "Crea un espacio de trabajo que asigna carpetas del depot a una carpeta local." }, + "Edit the current workspace spec — root folder and depot view mapping.": { ru: "Изменить спеку текущего воркспейса — корневую папку и маппинг депо.", de: "Bearbeite die aktuelle Arbeitsbereich-Spezifikation — Root-Ordner und Depot-View-Mapping.", fr: "Modifie la spec de l'espace de travail actuel — dossier racine et mappage du depot.", es: "Edita la spec del espacio de trabajo actual: carpeta raíz y mapeo del depot." }, + "Pick which depot folder the app shows and syncs.": { ru: "Выбрать, какую папку депо приложение показывает и синхронизирует.", de: "Wähle, welchen Depot-Ordner die App anzeigt und synchronisiert.", fr: "Choisis quel dossier du depot l'app affiche et synchronise.", es: "Elige qué carpeta del depot muestra y sincroniza la app." }, + "Sign out and return to the connection screen.": { ru: "Выйти и вернуться к экрану подключения.", de: "Abmelden und zum Verbindungsbildschirm zurückkehren.", fr: "Se déconnecter et revenir à l'écran de connexion.", es: "Cerrar sesión y volver a la pantalla de conexión." }, + "Re-read state from the server.": { ru: "Перечитать состояние с сервера.", de: "Zustand vom Server neu einlesen.", fr: "Relire l'état depuis le serveur.", es: "Volver a leer el estado desde el servidor." }, + "Scan the workspace for files changed outside the app (reconcile).": { ru: "Просканировать воркспейс на файлы, изменённые вне приложения (reconcile).", de: "Arbeitsbereich nach außerhalb der App geänderten Dateien durchsuchen (reconcile).", fr: "Analyser l'espace de travail pour les fichiers modifiés hors de l'app (reconcile).", es: "Analiza el espacio de trabajo en busca de archivos cambiados fuera de la app (reconcile)." }, + "Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V.": { ru: "Включить/выключить офлайн-режим: не опрашивать сервер, править файлы локально и согласовать позже. Как в P4V.", de: "Offline-Modus umschalten: Server nicht abfragen, Dateien lokal bearbeiten und später abgleichen. Wie in P4V.", fr: "Basculer le mode hors ligne : ne plus interroger le serveur, éditer localement et réconcilier plus tard. Comme dans P4V.", es: "Alternar el modo sin conexión: dejar de consultar el servidor, editar en local y reconciliar después. Como en P4V." }, + "Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them.": { ru: "Догнать сервер работой, сделанной офлайн: открывает локальные add / edit / delete в changelist для отправки.", de: "Den Server mit offline erledigter Arbeit nachziehen: öffnet lokale Adds / Edits / Deletes in einer Changelist zum Übermitteln.", fr: "Mettre le serveur à jour avec le travail hors ligne : ouvre les ajouts / modifications / suppressions locaux dans une changelist à soumettre.", es: "Poner al día el servidor con el trabajo sin conexión: abre adds / edits / deletes locales en una changelist para enviarlos." }, + "Make the workspace exactly match the depot — discards un-opened local changes.": { ru: "Привести воркспейс точно к состоянию депо — отбрасывает неоткрытые локальные правки.", de: "Arbeitsbereich exakt an das Depot angleichen — verwirft nicht geöffnete lokale Änderungen.", fr: "Aligner exactement l'espace de travail sur le depot — abandonne les modifications locales non ouvertes.", es: "Hacer que el espacio de trabajo coincida exactamente con el depot — descarta cambios locales no abiertos." }, + "Sync the workspace to the newest revision (pull).": { ru: "Синхронизировать воркспейс до последней ревизии (pull).", de: "Arbeitsbereich auf die neueste Revision synchronisieren (Pull).", fr: "Synchroniser l'espace de travail sur la dernière révision (pull).", es: "Sincronizar el espacio de trabajo a la revisión más reciente (pull)." }, + "Sync the workspace to a specific changelist, label or date.": { ru: "Синхронизировать воркспейс до конкретного changelist, метки или даты.", de: "Arbeitsbereich auf eine bestimmte Changelist, ein Label oder ein Datum synchronisieren.", fr: "Synchroniser l'espace de travail sur une changelist, un label ou une date précis.", es: "Sincronizar el espacio de trabajo a una changelist, etiqueta o fecha concretas." }, + "Save selected files into a local pending changelist (not sent yet).": { ru: "Сохранить выбранные файлы в локальный pending changelist (пока не отправлено).", de: "Ausgewählte Dateien in eine lokale offene Changelist speichern (noch nicht gesendet).", fr: "Enregistrer les fichiers sélectionnés dans une changelist locale en attente (pas encore envoyée).", es: "Guardar los archivos seleccionados en una changelist local pendiente (aún no enviada)." }, + "Send pending changelists to the server (push).": { ru: "Отправить pending changelists на сервер (push).", de: "Offene Changelists an den Server senden (Push).", fr: "Envoyer les changelists en attente au serveur (push).", es: "Enviar las changelists pendientes al servidor (push)." }, + "Resolve merge conflicts left after a sync.": { ru: "Разрешить конфликты слияния, оставшиеся после синхронизации.", de: "Nach einer Synchronisierung verbliebene Merge-Konflikte auflösen.", fr: "Résoudre les conflits de fusion restants après une synchronisation.", es: "Resolver los conflictos de fusión que quedan tras una sincronización." }, + "Discard every open change and restore depot versions.": { ru: "Отменить все открытые правки и восстановить версии из депо.", de: "Alle offenen Änderungen verwerfen und Depot-Versionen wiederherstellen.", fr: "Abandonner toutes les modifications ouvertes et restaurer les versions du depot.", es: "Descartar todos los cambios abiertos y restaurar las versiones del depot." }, + "Show the panel of recently run Perforce commands.": { ru: "Показать панель недавно выполненных команд Perforce.", de: "Zeige das Panel der zuletzt ausgeführten Perforce-Befehle.", fr: "Afficher le panneau des commandes Perforce récemment exécutées.", es: "Mostrar el panel de comandos de Perforce ejecutados recientemente." }, + "Open an embedded terminal for raw p4 commands.": { ru: "Открыть встроенный терминал для сырых команд p4.", de: "Ein eingebettetes Terminal für rohe p4-Befehle öffnen.", fr: "Ouvrir un terminal intégré pour les commandes p4 brutes.", es: "Abrir una terminal integrada para comandos p4 en crudo." }, + "Show output from the headless Unreal preview process.": { ru: "Показать вывод фонового процесса Unreal-превью.", de: "Ausgabe des Headless-Unreal-Vorschauprozesses anzeigen.", fr: "Afficher la sortie du processus de prévisualisation Unreal sans interface.", es: "Mostrar la salida del proceso de vista previa de Unreal sin interfaz." }, + "See every file currently exclusively locked and by whom.": { ru: "Посмотреть все файлы, сейчас взятые на эксклюзивный лок, и кем.", de: "Alle aktuell exklusiv gesperrten Dateien und von wem sehen.", fr: "Voir tous les fichiers actuellement verrouillés en exclusivité et par qui.", es: "Ver todos los archivos bloqueados en exclusiva ahora mismo y por quién." }, + "Find files anywhere in the depot by name or path.": { ru: "Найти файлы в любом месте депо по имени или пути.", de: "Dateien überall im Depot nach Name oder Pfad finden.", fr: "Trouver des fichiers n'importe où dans le depot par nom ou chemin.", es: "Buscar archivos en cualquier lugar del depot por nombre o ruta." }, + "Set which binary asset types are exclusive-checkout (+l) so only one person edits them.": { ru: "Задать, какие бинарные типы ассетов берутся эксклюзивно (+l), чтобы их правил только один человек.", de: "Festlegen, welche binären Asset-Typen exklusiv ausgecheckt werden (+l), damit nur eine Person sie bearbeitet.", fr: "Définir quels types d'assets binaires sont en extraction exclusive (+l) pour qu'une seule personne les édite.", es: "Definir qué tipos de assets binarios son de extracción exclusiva (+l) para que solo una persona los edite." }, + "Named snapshots of file revisions: tag files, or sync to a label.": { ru: "Именованные снимки ревизий файлов: пометить файлы или синхронизироваться на метку.", de: "Benannte Schnappschüsse von Dateirevisionen: Dateien taggen oder auf ein Label synchronisieren.", fr: "Instantanés nommés de révisions de fichiers : taguer des fichiers ou synchroniser sur un label.", es: "Instantáneas con nombre de revisiones de archivos: etiquetar archivos o sincronizar a una etiqueta." }, + "Move changes between branches of the depot.": { ru: "Переносить изменения между ветками депо.", de: "Änderungen zwischen Depot-Branches verschieben.", fr: "Déplacer des modifications entre les branches du depot.", es: "Mover cambios entre ramas del depot." }, + "Switch stream, merge down from the parent, or copy up to it.": { ru: "Переключить стрим, влить сверху от родителя (merge down) или протолкнуть наверх (copy up).", de: "Stream wechseln, vom Parent mergen (merge down) oder nach oben kopieren (copy up).", fr: "Changer de stream, fusionner depuis le parent (merge down) ou remonter (copy up).", es: "Cambiar de stream, fusionar desde el padre (merge down) o copiar hacia arriba (copy up)." }, + "Perforce's task/bug tracker — create jobs and attach them to changelists.": { ru: "Трекер задач/багов Perforce — создавай джобы и привязывай к changelists.", de: "Perforce-Aufgaben-/Bug-Tracker — Jobs erstellen und an Changelists anhängen.", fr: "Le suivi de tâches/bugs de Perforce — crée des jobs et attache-les aux changelists.", es: "El rastreador de tareas/errores de Perforce — crea jobs y adjúntalos a changelists." }, + "Edit which files reconcile / add ignore (build output, caches).": { ru: "Изменить, какие файлы игнорируют reconcile / add (сборка, кэши).", de: "Bearbeiten, welche Dateien reconcile / add ignorieren (Build-Ausgabe, Caches).", fr: "Modifier quels fichiers reconcile / add ignorent (sortie de build, caches).", es: "Editar qué archivos ignoran reconcile / add (salida de compilación, cachés)." }, + "Compile the Visual Studio solution found in the working folder.": { ru: "Скомпилировать решение Visual Studio, найденное в рабочей папке.", de: "Die im Arbeitsordner gefundene Visual-Studio-Solution kompilieren.", fr: "Compiler la solution Visual Studio trouvée dans le dossier de travail.", es: "Compilar la solución de Visual Studio encontrada en la carpeta de trabajo." }, + "See who works on this depot and their roles.": { ru: "Посмотреть, кто работает с этим депо и их роли.", de: "Sehen, wer an diesem Depot arbeitet und welche Rollen sie haben.", fr: "Voir qui travaille sur ce depot et leurs rôles.", es: "Ver quién trabaja en este depot y sus roles." }, + "App preferences — language, theme, editor, and more.": { ru: "Настройки приложения — язык, тема, редактор и прочее.", de: "App-Einstellungen — Sprache, Design, Editor und mehr.", fr: "Préférences de l'app — langue, thème, éditeur et plus.", es: "Preferencias de la app — idioma, tema, editor y más." }, + "Version and information about Exbyte Depot.": { ru: "Версия и информация о Exbyte Depot.", de: "Version und Informationen zu Exbyte Depot.", fr: "Version et informations sur Exbyte Depot.", es: "Versión e información sobre Exbyte Depot." }, }; export function t(en: string, vars?: Record): string { diff --git a/src/p4.ts b/src/p4.ts index e7e9277..c461af6 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -74,6 +74,9 @@ export const p4 = { filelog: (depot: string) => invoke>("p4_filelog", { depot }), cleanPreview: (scope = "") => invoke("p4_clean_preview", { scope }), cleanApply: (scope = "") => invoke("p4_clean_apply", { scope }), + reconcilePreview: (scope = "") => invoke("p4_reconcile_preview", { scope }), + reconcileApply: (paths: string[]) => invoke("p4_reconcile_apply", { paths }), + setWritable: (paths: string[], writable: boolean) => invoke("p4_set_writable", { paths, writable }), jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"), fix: (job: string, change: string) => invoke("p4_fix", { job, change }), jobSpec: (job: string) => invoke("p4_job_spec", { job }),