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 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 12:46:37 +03:00
parent 5d647f8e92
commit ec8f31c654
8 changed files with 294 additions and 18 deletions

View File

@ -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<OpenedFile[]>([]); // 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<string>(""); // newest submitted CL seen (new-submit toast)
const notifPrimed = useRef(false); // don't toast on the very first poll
const [buildLog, setBuildLog] = useState<string[]>([]); // 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<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; act?: () => 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<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; hint?: string; act?: () => 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)}
<div className="dropdown">
{items.map((it) => (
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}>
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()} title={it.hint}>
{it.icon && <span className="di-ic">{it.icon}</span>}
{it.label}{it.kb && <span className="kb">{it.kb}</span>}
</div>
@ -1164,9 +1219,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
</div>
{offline && (
{workOffline ? (
<div className="netbar offmode">
{I.power}{t("Working offline — edits stay on your disk. Reconcile to bring them into a changelist.")}
<button className="netbar-btn" onClick={() => setModal({ kind: "reconcile" })}>{t("Reconcile…")}</button>
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Go online")}</button>
</div>
) : offline && (
<div className="netbar off">
<span className="ldr sm" />{t("Disconnected from the server — retrying…")}
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Work offline instead")}</button>
</div>
)}
{needResolve.length > 0 && tab === "changes" && (
@ -1359,6 +1421,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "reconcile" && <ReconcileModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => 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<OpenedFile[]>([]);
const [busy, setBusy] = useState(true);
const [applying, setApplying] = useState(false);
const [sel, setSel] = useState<Set<string>>(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<string, number>;
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 (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.scan}<h3>{t("Reconcile Offline Work")}</h3><span className="ph-sub">{scope || "//…"} · {t("{n} changes", { n: files.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="tm-hint">{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.")}</div>
{!busy && files.length > 0 && (
<div className="rec-summary">
<span style={{ color: "var(--add)", fontWeight: 700 }}>+{counts.add}</span> {t("added")} · <span style={{ color: "var(--edit)", fontWeight: 700 }}>±{counts.edit}</span> {t("modified")} · <span style={{ color: "var(--del)", fontWeight: 700 }}>{counts.delete}</span> {t("deleted")}
<span className="rec-all" onClick={toggleAll}>{allOn ? t("Deselect all") : t("Select all")}</span>
</div>
)}
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning workspace for offline changes…")}</div>}
{!busy && files.length === 0 && <div className="ur-empty">{t("Nothing to reconcile — the workspace matches what the server knows.")}</div>}
{files.map((f) => {
const dp = keyOf(f);
const sp = splitPath(dp);
const st = statusOf(f.action);
const on = sel.has(dp);
return (
<div key={dp} className="srch-row rec-row" onClick={() => toggle(dp)}>
<span className={"chk" + (on ? "" : " off")}>{on ? I.check : null}</span>
<span className={"stat " + st.cls} style={{ marginRight: 4 }}>{st.ch}</span>
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
<span className="lk-who">{f.action}</span>
</div>
);
})}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={applying}>{t("Cancel")}</button>
<button className="mbtn" onClick={apply} disabled={applying || busy || sel.size === 0}>{applying ? <span className="ldr sm" /> : null}{t("Reconcile {n} file(s)", { n: sel.size })}</button>
</div>
</div>
</div>
);
}
/* ---------------- 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<FileRev[]>([]);