v0.3.2 — commit/submit progress, cancel, thumbnails, waitlist landing+admin

Client:
- Commit: batch `p4 reopen` so huge changelists (63k+) no longer blow the
  Windows command-line limit; live "N / total" progress + spinner during commit.
- Submit dialog: white arrow, "N of total" + % bar, running file log,
  Cancel button (aborts pre-commit; changelist stays pending).
- Disk scan: streaming reconcile with a live "found N files" counter + Cancel.
- Thumbnails: only decode browser-renderable image formats; glyph fallback
  (+ onError) instead of a broken-image icon for bmp/tga/dds/etc.

Landing + waitlist:
- waitlist-server: Express + SQLite email collector (dedup) with a secure
  admin panel (scrypt auth, rate-limited login, signed cookies) and CSV export.
- landing form now POSTs signups to the waitlist API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 20:40:53 +03:00
parent 5652f489a2
commit 3954082e8d
21 changed files with 3753 additions and 144 deletions

View File

@ -235,6 +235,12 @@ body.resizing{cursor:col-resize!important;user-select:none}
.scanbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;font-size:11.5px;line-height:1.4;
color:var(--accent-2);background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22)}
.scanbar .ldr{flex:0 0 auto}
.scanbar > span{flex:1 1 auto;min-width:0}
.scancount{font-weight:600;color:var(--txt);font-variant-numeric:tabular-nums}
.scan-cancel{flex:0 0 auto;font-size:11px;font-weight:500;color:var(--muted);cursor:pointer;
background:var(--panel);border:1px solid var(--border);border-radius:7px;padding:3px 11px;transition:.15s}
.scan-cancel:hover{color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
.empty .scancount{color:var(--accent-2)}
/* submit progress modal */
.push-modal{width:380px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:24px 26px;box-shadow:var(--shadow);text-align:center}
@ -442,9 +448,20 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.xfer-ttl b{font-size:14.5px}
.xfer-ttl span{font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
.xfer-file{margin-top:14px;font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xfer-bar{margin-top:12px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
.xfer-cancel{margin-left:auto;flex:0 0 auto;align-self:flex-start;font-size:11.5px;font-weight:600;cursor:pointer;
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;padding:5px 11px;transition:.15s}
.xfer-cancel:hover:not(:disabled){color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
.xfer-cancel:disabled{opacity:.55;cursor:default}
.xfer-bar{margin-top:14px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
.xfer-bar span{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:4px;
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite}
/* determinate (known total): solid fill sized by inline width, no shimmer */
.xfer-bar.det span{position:absolute;left:0;top:0;width:0;animation:none;
background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease;box-shadow:0 0 10px rgba(124,110,246,.55)}
.xfer-log{margin-top:12px;height:132px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:9px;
background:var(--panel);padding:7px 10px;font-family:var(--mono);font-size:10.5px;line-height:1.55;color:var(--muted)}
.xfer-log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xfer-log div:last-child{color:var(--txt)}
/* MSBuild output overlay */
.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column;
@ -666,7 +683,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */
.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto}
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto;overflow-y:hidden}
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px}
.vtab:hover{color:var(--txt);background:var(--hover)}
.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)}
@ -681,6 +698,19 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto}
.ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
.ft-total b{color:var(--accent-2)}
.ft-new{display:flex;align-items:center;gap:5px;font-family:var(--font);font-size:11.5px;font-weight:600;color:var(--accent-2);
background:rgba(124,110,246,.1);border:1px solid rgba(124,110,246,.24);border-radius:8px;padding:4px 10px;cursor:pointer;flex:0 0 auto;transition:.14s}
.ft-new:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
.ft-new svg{width:13px;height:13px}
/* folder properties dialog */
.fp-body{padding:18px 20px}
.fp-name{display:flex;align-items:center;gap:10px;font-size:15px;margin-bottom:16px}
.fp-name svg{width:20px;height:20px;color:var(--accent-2);flex:0 0 auto}
.fp-name b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fp-grid{display:grid;grid-template-columns:auto 1fr;gap:10px 16px;font-size:13px}
.fp-grid .k{color:var(--faint)}
.fp-grid .v{color:var(--txt);min-width:0;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
.fp-grid .v.mono{font-family:var(--mono);font-size:12px;word-break:break-all;white-space:normal}
.vpane.tree .exp-tree{flex:1;min-height:0}
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
.hbody{display:flex;flex-direction:column;gap:3px;min-width:0}
@ -722,6 +752,13 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.picker-item .pi-body{display:flex;flex-direction:column;gap:2px;min-width:0}
.picker-item .pi-name{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.picker-item .pi-sub{font-size:11.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* recently-used working folders */
.pick-recent{border-bottom:1px solid var(--border-soft);margin-bottom:6px;padding-bottom:6px}
.pick-recent-h{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);padding:4px 12px 6px;font-weight:600}
.picker-item .pi-ic.clock,.picker-item .pi-ic.up{color:var(--muted);background:var(--panel-3)}
/* "local only, not in depot yet" tag */
.local-tag{margin-left:8px;font-size:9.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--warn);
background:rgba(232,176,75,.14);border:1px solid rgba(232,176,75,.32);border-radius:6px;padding:1px 6px;vertical-align:middle}
.picker-foot{padding:12px 20px;border-top:1px solid var(--border-soft);font-size:12px;color:var(--faint)}
.info-body{padding:20px;font-size:13px;line-height:1.6;color:var(--muted)}
.info-body b{color:var(--txt)}

View File

@ -8,7 +8,7 @@ import ModelViewer from "./ModelViewer";
import CodeView from "./CodeView";
import UpdateBanner from "./Updater";
import "./App.css";
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry } from "./p4";
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
@ -70,6 +70,13 @@ const I = {
info: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6"/><path d="M12 11v5M12 8h.01" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
scan: <svg viewBox="0 0 24 24" fill="none"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M4 12h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
trash: <svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="M18 7l-.8 12a2 2 0 0 1-2 1.9H8.8a2 2 0 0 1-2-1.9L6 7" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 11v5M14 11v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
copy: <svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
reveal: <svg viewBox="0 0 24 24" fill="none"><path d="M3 8a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2M3 8v9a2 2 0 0 0 2 2h13.5a1.5 1.5 0 0 0 1.46-1.14L21.5 12H8.2a1.5 1.5 0 0 0-1.46 1.14L5 20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>,
move: <svg viewBox="0 0 24 24" fill="none"><path d="M14 5h5a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5M3 12h11M10 8l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
pencil: <svg viewBox="0 0 24 24" fill="none"><path d="m14.5 5.5 4 4M4 20l1-4L16.5 4.5a2 2 0 0 1 3 3L8 19l-4 1Z" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
down: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
open: <svg viewBox="0 0 24 24" fill="none"><path d="M14 4h6v6M20 4l-8 8M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};
/* ---------------- theme hook ---------------- */
@ -331,6 +338,7 @@ type ModalState =
| { kind: "clean" }
| { kind: "reconcile" }
| { kind: "explorer" }
| { kind: "folderprops"; path: string; name: string }
| { kind: "ignore" }
| { kind: "filelog"; depot: string; name: string }
| { kind: "client"; name: string; isNew: boolean }
@ -339,7 +347,7 @@ type ModalState =
| { kind: "about" };
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
type Transfer = { op: "sync" | "submit"; count: number; file?: string };
type Transfer = { op: "sync" | "submit"; count: number; total?: number; file?: string; log: string[]; cancelling?: boolean };
type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean };
function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
@ -362,6 +370,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]);
const [busy, setBusy] = useState(false);
const [scanning, setScanning] = useState(false); // background reconcile in progress
const [scanCount, setScanCount] = useState(0); // live "found N files" during a streaming scan
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
const [history, setHistory] = useState<Change[]>([]);
@ -374,6 +383,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
const [commitProg, setCommitProg] = useState<{ count: number; total: number } | null>(null); // live commit (reopen) progress
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
@ -587,9 +597,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
finally { if (seq === refreshSeq.current) setBusy(false); }
if (scan) {
setScanning(true);
try { await p4.scan(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); }
setScanCount(0);
// stream the reconcile so the banner shows a live "found N files" counter
// (and can be cancelled) instead of an opaque, never-ending spinner
try { await p4.scanStream(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); }
catch (e) { if (seq === refreshSeq.current) flash(String(e), true); }
finally { if (seq === refreshSeq.current) setScanning(false); }
finally { if (seq === refreshSeq.current) { setScanning(false); setScanCount(0); } }
}
}
// Default = Perforce-native: just read what's checked out (p4 opened). With
@ -607,13 +620,38 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
}).then((u) => { if (dead) u(); else un = u; });
return () => { dead = true; un?.(); };
}, []);
// live file-transfer progress (Get Latest / Submit) streamed from the backend
// live file-transfer progress (Get Latest / Submit) streamed from the backend.
// Accumulates a rolling file log and tracks total for an "N of total" bar.
useEffect(() => {
let un: (() => void) | undefined; let dead = false;
listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
listen<{ op: "sync" | "submit"; count: number; total?: number; file?: string; done: boolean; cancelled?: boolean }>("p4-transfer", (e) => {
const p = e.payload;
if (p.done) setTransfer(null);
else setTransfer({ op: p.op, count: p.count, file: p.file });
if (p.done) { setTransfer(null); return; }
setTransfer((prev) => {
const log = prev ? prev.log : [];
const next = p.file && (log.length === 0 || log[log.length - 1] !== p.file)
? [...log, p.file].slice(-200) : log;
return { op: p.op, count: p.count, total: p.total || prev?.total, file: p.file, log: next, cancelling: prev?.cancelling };
});
}).then((u) => { if (dead) u(); else un = u; });
return () => { dead = true; un?.(); };
}, []);
// live commit progress: p4_commit reopens files in batches and emits one event
// per batch, so we can show "N / total committed" instead of a mute spinner
useEffect(() => {
let un: (() => void) | undefined; let dead = false;
listen<{ count: number; total: number; done: boolean }>("p4-commit", (e) => {
const p = e.payload;
setCommitProg(p.done ? null : { count: p.count, total: p.total });
}).then((u) => { if (dead) u(); else un = u; });
return () => { dead = true; un?.(); };
}, []);
// live disk-scan progress: the streaming reconcile emits one event per file it
// opens, so we can show a rising "found N files" counter during a Rescan
useEffect(() => {
let un: (() => void) | undefined; let dead = false;
listen<{ count: number; file?: string; done: boolean }>("p4-scan", (e) => {
if (!e.payload.done) setScanCount(e.payload.count);
}).then((u) => { if (dead) u(); else un = u; });
return () => { dead = true; un?.(); };
}, []);
@ -705,6 +743,23 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
// remember which view-panel tabs are open across restarts
useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]);
// In a shipped (production) build, kill the WebView's own right-click menu
// (Back / Reload / Save as / Print / Inspect) and the devtools hotkeys — this
// is a desktop app, not a web page. In dev we keep them for debugging.
useEffect(() => {
if (import.meta.env.DEV) return;
const noCtx = (e: MouseEvent) => e.preventDefault(); // our own context menus render as React popups, unaffected
const noKeys = (e: KeyboardEvent) => {
const k = e.key.toUpperCase();
if (k === "F12" || (e.ctrlKey && e.shiftKey && (k === "I" || k === "J" || k === "C")) || (e.ctrlKey && k === "U")) {
e.preventDefault();
}
};
document.addEventListener("contextmenu", noCtx);
window.addEventListener("keydown", noKeys, true);
return () => { document.removeEventListener("contextmenu", noCtx); window.removeEventListener("keydown", noKeys, true); };
}, []);
// check "am I behind the server" when connected or the working folder changes
useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]);
@ -800,6 +855,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
setModal(null);
setActivePath(path);
saveScope(info?.clientName || "", path); // remember the working folder per workspace
if (path) pushRecentScope(info?.clientName || "", path); // Recently list (skip whole-workspace)
setHistory([]); setDetail(null); setSel(null);
refresh(path);
if (tab === "history") loadHistory(path);
@ -867,6 +923,50 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
function newWorkspace() {
setPrompt({ title: t("New workspace"), label: t("Workspace (client) name"), value: `${session?.user || info?.userName || "user"}_new`, onSave: (v) => { setPrompt(null); const n = v.trim(); if (n) setModal({ kind: "client", name: n, isNew: true }); } });
}
// create a new folder / project in the workspace under `scope` (depot path, or "" = root)
function newFolder(scope: string) {
const where = scope || t("workspace root");
setPrompt({ title: t("New folder in {where}", { where }), label: t("Folder name — nest with / (e.g. NewGame or NewGame/Source)"), value: "", onSave: async (v) => {
setPrompt(null);
const n = v.trim();
if (!n) return;
try {
await p4.makeFolder(scope, n);
flash(t("Created “{name}” with a README placeholder — commit it to keep the folder on the server.", { name: n }));
await refresh();
} catch (e) { flash(String(e), true); }
} });
}
// rename / move a depot folder (p4 move) — review in Changes, then Submit
function renameFolder(path: string) {
const cur = splitPath(path).name || path;
setPrompt({ title: t("Rename folder"), label: t("New name for “{name}”", { name: cur }), value: cur, onSave: async (v) => {
setPrompt(null);
const n = v.trim();
if (!n || n === cur) return;
try { const r = await p4.renameFolder(path, n); flash(t("Renamed to “{name}” — {n} file(s) moved. Commit to apply.", { name: n, n: r.length })); await refresh(); }
catch (e) { flash(String(e), true); }
} });
}
// delete a folder — marks its files for delete (reviewable), removes fresh/local ones
async function deleteFolder(path: string) {
const name = splitPath(path).name || path;
if (!(await confirm({ title: t("Delete folder “{name}”?", { name }), body: t("Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away."), confirm: t("Delete"), danger: true }))) return;
try { const r = await p4.deleteFolder(path); flash(r.length ? t("Marked {n} file(s) for delete — Submit to remove the folder.", { n: r.length }) : t("Folder removed.")); await refresh(); }
catch (e) { flash(String(e), true); }
}
// right-click on a folder in the File Tree → folder operations
function folderMenu(n: FsEntry, e: ReactMouseEvent) {
const isDepot = n.path.startsWith("//");
openCtx(e, [
{ label: t("New folder / project…"), icon: I.folder, act: () => newFolder(n.path) },
...(isDepot ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(n.path) }] : []),
{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: n.path, name: n.name }) },
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(n.path) },
{ label: t("Copy path"), icon: I.copy, act: () => copyText(n.path, t("Path")) },
{ label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(n.path) },
]);
}
// Manual re-scan (same background reconcile as auto, on demand).
function rescan() { refresh(activePath, true); }
@ -882,15 +982,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
// open the configuration picker (Development / Shipping / …) before building
function startBuild() {
if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
if (!slnPath && !uproject) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
setBuildPick(true);
}
// build the solution (MSBuild) with the chosen UE configuration, live output
// build with the chosen UE configuration, live output. For a real UE project we
// pass the .uproject so the backend builds the game module via UnrealBuildTool.
async function buildSln(config: string) {
setBuildPick(false);
if (!slnPath) return;
if (!slnPath && !uproject) return;
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
try { await p4.buildSln(slnPath, config); }
try { await p4.buildSln(slnPath, config, uproject); }
catch { /* the overlay already shows the failure line */ }
}
@ -954,6 +1055,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
} finally { setAiBusy(false); }
}
// Cancel an in-progress submit/sync. Safe for submit before the server commit:
// p4 submit is transactional, so an aborted transfer leaves the CL pending.
function cancelTransfer() {
setTransfer((prev) => (prev ? { ...prev, cancelling: true } : prev));
p4.transferCancel().catch(() => {});
}
// Submit / push: send all committed (pending) changelists to the server. (like git push)
async function pushAll() {
if (!pending.length) return;
@ -1112,16 +1220,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const dps = targets.map((f) => f.depotFile || "").filter(Boolean);
const isCode = isCodeFile(splitPath(dp).name);
openCtx(e, [
{ label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) },
{ label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), icon: I.trash, danger: true, act: () => discard(targets) },
{ 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) })),
...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), icon: I.move, 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 }) }] : []),
{ label: t("Open in Explorer"), act: () => reveal(dp) },
{ label: t("Copy path"), act: () => copyText(dp, t("Path")) },
...(isCode ? [{ label: t("Blame (annotate)"), icon: I.people, act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(dp) },
{ label: t("Copy path"), icon: I.copy, act: () => copyText(dp, t("Path")) },
]);
}
async function lockFiles(dps: string[], lock: boolean) {
@ -1204,6 +1312,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
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 },
{ label: t("New folder / project…"), icon: I.folder, hint: t("Create a new folder in the working area (with a README placeholder) — e.g. a separate project."), act: () => newFolder(activePath) },
...(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 },
@ -1242,7 +1351,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{ 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: (slnPath || uproject) ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild."), 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" }) },
],
@ -1278,9 +1387,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("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")) },
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(String(info?.clientRoot || "")) },
{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces },
{ label: t("Copy name"), icon: I.copy, act: () => copyText(info?.clientName || "", t("Workspace name")) },
])}>
<span className="ic">{I.monitor}</span>
<span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
@ -1290,12 +1399,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<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: t("New folder / project…"), icon: I.folder, act: () => newFolder(activePath) },
...(activePath ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(activePath) }] : []),
...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []),
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
{ 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")) },
...((slnPath || uproject) ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") },
{ label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) },
...(activePath ? [{ label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(activePath) }] : []),
])}>
<span className="ic">{I.branch}</span>
<span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
@ -1372,16 +1485,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<span className="cl-actions" onClick={(e) => e.stopPropagation()}>
<button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}></button>
<button className="cl-more" onClick={(e) => openCtx(e, [
{ label: t("Edit description"), act: () => editDesc(cl) },
{ label: t("Edit description"), icon: I.pencil, act: () => editDesc(cl) },
{ label: t("Attach job…"), icon: I.log, act: () => attachJob(ch) },
{ label: t("Submit & keep checked out (-r)"), icon: I.up, act: () => submitWithOpts(ch, true, false) },
{ label: t("Submit, reverting unchanged"), icon: I.up, act: () => submitWithOpts(ch, false, true) },
{ label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
{ label: t("Unshelve into workspace"), act: () => unshelveCL(ch) },
{ label: t("Unshelve into workspace"), icon: I.down, act: () => unshelveCL(ch) },
{ label: t("Submit shelved (-e)"), icon: I.up, act: () => submitShelved(ch) },
{ label: t("Delete shelved files"), danger: true, act: () => deleteShelfCL(ch) },
{ label: t("Uncommit (back to working)"), act: () => uncommit(cf) },
{ label: t("Discard changes · {n} files", { n: cf.length }), danger: true, act: () => discard(cf) },
{ label: t("Delete shelved files"), icon: I.trash, danger: true, act: () => deleteShelfCL(ch) },
{ label: t("Uncommit (back to working)"), icon: I.back, act: () => uncommit(cf) },
{ label: t("Discard changes · {n} files", { n: cf.length }), icon: I.trash, danger: true, act: () => discard(cf) },
])} title={t("More")}></button>
</span>
</div>
@ -1408,7 +1521,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{scanning && (
<div className="scanbar">
<span className="ldr" />
<span>{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}</span>
<span>
{t("Scanning disk for changes…")}
{scanCount > 0 && <b className="scancount"> {t("found {n} files", { n: scanCount.toLocaleString() })}</b>}
{scanCount === 0 && !activePath && t(" for the whole workspace this is slow — pick a project folder above")}
</span>
<button className="scan-cancel" onClick={() => { p4.scanCancel().catch(() => {}); }} title={t("Stop the scan. Files already found stay staged.")}>{t("Cancel")}</button>
</div>
)}
<div className="chhead">
@ -1420,7 +1538,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
{view.length === 0 ? (
<div className="empty">
{scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}</> : <>
{scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}{scanCount > 0 && <b className="scancount"> {t("found {n} files", { n: scanCount.toLocaleString() })}</b>}</> : <>
<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 ? 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”.")}
</>}
@ -1440,7 +1558,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<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}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b>
{commitProg ? (
<><span className="ldr sm" />{t("Committing…")} <b>{commitProg.count.toLocaleString()} / {commitProg.total.toLocaleString()}</b></>
) : (
<>{I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b></>
)}
</button>
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
</div>
@ -1448,11 +1570,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
) : (
<HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
onContext={(c, e) => openCtx(e, [
{ label: t("Open this changelist"), act: () => openChange(c) },
{ label: t("Open this changelist"), icon: I.open, act: () => openChange(c) },
{ label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
{ 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")) },
{ label: t("Revert (undo) #{n}", { n: c.change || "" }), icon: I.revert, danger: true, act: () => revertChange(c) },
{ label: t("Copy number #{n}", { n: c.change || "" }), icon: I.copy, act: () => copyText(c.change || "", t("Number")) },
{ label: t("Copy description"), icon: I.copy, act: () => copyText((c.desc || "").trim(), t("Description")) },
])} />
)}
</div>
@ -1499,7 +1621,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
)}
{viewTabs.includes("tree") && (
<div className="vpane tree" style={{ display: viewTab === "tree" ? "flex" : "none" }}>
<FileTree scope={activePath} onReveal={reveal} onFlash={flash} onOpenFile={openTreeFile} initialSide={treeSide} sideNonce={treeNonce} />
<FileTree scope={activePath} onReveal={reveal} onFlash={flash} onOpenFile={openTreeFile} onNewFolder={(side) => newFolder(side === "depot" ? activePath : "")} onFolderMenu={folderMenu} initialSide={treeSide} sideNonce={treeNonce} />
</div>
)}
{viewTabs.includes("locks") && (
@ -1556,6 +1678,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{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 === "explorer" && <ExplorerModal scope={activePath} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} />}
{modal?.kind === "folderprops" && <FolderPropsModal path={modal.path} name={modal.name} onClose={() => setModal(null)} onReveal={reveal} onRename={() => { const p = modal.path; setModal(null); renameFolder(p); }} onDelete={() => { const p = modal.path; setModal(null); deleteFolder(p); }} onCopy={(p) => copyText(p, t("Path"))} />}
{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(); } }} />}
@ -1564,7 +1687,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => setResolveOpen(false)} />}
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{prompt && <TextPrompt title={prompt.title} label={prompt.label} value={prompt.value} onSave={prompt.onSave} onClose={() => setPrompt(null)} />}
{showXfer && transfer && <TransferDialog transfer={transfer} />}
{showXfer && transfer && <TransferDialog transfer={transfer} onCancel={cancelTransfer} />}
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
@ -1605,6 +1728,7 @@ function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoo
{modal.kind === "scope" && (() => {
const path = modal.path;
const recents = recentScopes(info?.clientName || "");
const parts = path.replace(/^\/+/, "").split("/").filter(Boolean); // e.g. ["depot","Proj"]
const parentPath = parts.length ? "//" + parts.slice(0, -1).join("/") : "";
const up = parts.length > 0;
@ -1619,6 +1743,18 @@ function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoo
</div>
</div>
<div className="picker-list">
{recents.length > 0 && (
<div className="pick-recent">
<div className="pick-recent-h">{t("Recently")}</div>
{recents.map((rp) => (
<div key={rp} className="picker-item" onClick={() => onChoose(rp)}>
<span className="pi-ic clock"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" /><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg></span>
<span className="pi-body"><span className="pi-name">{leaf(rp)}</span><span className="pi-sub">{rp}</span></span>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
</div>
))}
</div>
)}
{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>
@ -1629,7 +1765,7 @@ function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoo
{modal.dirs.map((d) => (
<div key={d.dir} className="picker-item" onClick={() => onBrowse(d.dir || "")}>
<span className="pi-ic">{folder}</span>
<span className="pi-body"><span className="pi-name">{leaf(d.dir)}</span><span className="pi-sub">{d.dir}</span></span>
<span className="pi-body"><span className="pi-name">{leaf(d.dir)}{d.local ? <span className="local-tag">{t("local")}</span> : null}</span><span className="pi-sub">{d.dir}</span></span>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
</div>
))}
@ -1987,24 +2123,40 @@ function UnrealTab({ uproject }: { uproject: string }) {
}
/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer }: { transfer: Transfer }) {
function TransferDialog({ transfer, onCancel }: { transfer: Transfer; onCancel: () => void }) {
const up = transfer.op === "submit";
const total = transfer.total || 0;
const pct = total > 0 ? Math.min(100, Math.round((transfer.count / total) * 100)) : 0;
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [transfer.log.length]);
return (
<div className="modal-back xfer-back">
<div className="xfer">
<div className="xfer-head">
<span className={"xfer-ic" + (up ? " up" : " down")}>
{up
? <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>
: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
? <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>
: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
<div className="xfer-ttl">
<b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
<span>{t("{n} files", { n: transfer.count })}</span>
<span>{total > 0
? `${transfer.count.toLocaleString()} / ${total.toLocaleString()} · ${pct}%`
: t("{n} files", { n: transfer.count })}</span>
</div>
{up && (
<button className="xfer-cancel" onClick={onCancel} disabled={transfer.cancelling}
title={t("Stop before the server commit. Files already sent are not applied; the changelist stays pending.")}>
{transfer.cancelling ? t("Cancelling…") : t("Cancel")}
</button>
)}
</div>
<div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>
<div className="xfer-bar"><span /></div>
<div className={"xfer-bar" + (total > 0 ? " det" : "")}>
<span style={total > 0 ? { width: pct + "%" } : undefined} />
</div>
{transfer.log.length > 0
? <div className="xfer-log" ref={logRef}>{transfer.log.map((f, i) => <div key={i}>{f}</div>)}</div>
: <div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>}
</div>
</div>
);
@ -2249,7 +2401,7 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string;
}
/* ---------------- .p4ignore editor ---------------- */
const P4IGNORE_TEMPLATE = "# Unreal Engine — keep generated / local files out of Perforce\nBinaries/\nBuild/\nDerivedDataCache/\nIntermediate/\nSaved/\n.vs/\n*.sln\n*.suo\n*.opensdf\n*.sdf\n*.VC.db\n*.VC.opendb\n";
const P4IGNORE_TEMPLATE = "# Unreal Engine — keep generated / local files out of Perforce\n# (build intermediates come down read-only and break rebuilds if committed)\nBinaries/\nBuild/\nDerivedDataCache/\nIntermediate/\nSaved/\nobj/\n.vs/\n*.sln\n*.suo\n*.opensdf\n*.sdf\n*.VC.db\n*.VC.opendb\n";
function IgnoreModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [text, setText] = useState("");
const [busy, setBusy] = useState(true);
@ -2336,6 +2488,43 @@ function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClos
);
}
/* ---------------- folder properties ---------------- */
function FolderPropsModal({ path, name, onClose, onReveal, onRename, onDelete, onCopy }: { path: string; name: string; onClose: () => void; onReveal: (p: string) => void; onRename: () => void; onDelete: () => void; onCopy: (p: string) => void }) {
const [info, setInfo] = useState<{ fileCount: number; size: number } | null>(null);
const isDepot = path.startsWith("//");
useEffect(() => {
let live = true;
p4.folderInfo(path).then((i) => live && setInfo(i)).catch(() => live && setInfo({ fileCount: 0, size: 0 }));
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [path]); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.folder}<h3>{t("Folder properties")}</h3>
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><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="fp-body">
<div className="fp-name">{I.folder}<b>{name}</b></div>
<div className="fp-grid">
<span className="k">{t("Path")}</span><span className="v mono">{path}</span>
<span className="k">{t("Files")}</span><span className="v">{info ? info.fileCount.toLocaleString() : "…"}</span>
<span className="k">{t("Total size")}</span><span className="v">{info ? humanSize(info.size) : "…"}</span>
<span className="k">{t("Location")}</span><span className="v">{isDepot ? t("depot") : t("local only")}</span>
</div>
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)", gap: 8 }}>
<button className="mbtn ghost" onClick={() => onReveal(path)}>{t("Open in Explorer")}</button>
<button className="mbtn ghost" onClick={() => onCopy(path)}>{t("Copy path")}</button>
{isDepot && <button className="mbtn ghost" onClick={onRename}>{t("Rename…")}</button>}
<button className="mbtn danger" style={{ marginLeft: "auto" }} onClick={onDelete}>{t("Delete folder…")}</button>
</div>
</div>
</div>
);
}
/* ---------------- 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.
@ -2418,7 +2607,7 @@ const sortBySize = (a: FsEntry[]) => [...a].sort((x, y) => (y.size - x.size) ||
// one row in the tree; folders lazily load their children on first expand,
// files trigger onOpen (open in the File Viewer / reveal).
function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void }) {
function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen, onMenu }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void; onMenu?: (n: FsEntry, e: ReactMouseEvent) => void }) {
const [open, setOpen] = useState(false);
const [kids, setKids] = useState<FsEntry[] | null>(null);
const [busy, setBusy] = useState(false);
@ -2436,7 +2625,7 @@ function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node:
const childMax = Math.max(1, ...(kids || []).map((k) => k.size));
return (
<div className="exp-node">
<div className={"exp-row" + (node.isDir || onOpen ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={activate} title={node.path}>
<div className={"exp-row" + (node.isDir || onOpen ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={activate} onContextMenu={(e) => { if (node.isDir && onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(node, e); } }} title={node.path}>
<span className="exp-caret">{node.isDir ? (busy ? <span className="ldr sm" /> : <span className={"chevi" + (open ? " open" : "")}>{I.chevron}</span>) : null}</span>
<span className="exp-ic">{node.isDir ? I.folder : FILE_GLYPH}</span>
<span className="exp-name">{node.name}</span>
@ -2445,14 +2634,14 @@ function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node:
<span className="exp-size">{humanSize(node.size)}</span>
<span className="exp-open" onClick={(e) => { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder}</span>
</div>
{open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} onOpen={onOpen} />)}
{open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} onOpen={onOpen} onMenu={onMenu} />)}
</div>
);
}
// The tree body (Depot / Workspace toggle + lazy size tree), reused by the
// Files & Sizes modal AND the embedded File Tree view-panel.
function FileTree({ scope, onReveal, onFlash, onOpenFile, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) {
function FileTree({ scope, onReveal, onFlash, onOpenFile, onNewFolder, onFolderMenu, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; onNewFolder?: (side: "depot" | "workspace") => void; onFolderMenu?: (n: FsEntry, e: ReactMouseEvent) => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) {
const [side, setSide] = useState<"depot" | "workspace">(initialSide ?? "depot");
useEffect(() => { if (initialSide) setSide(initialSide); }, [sideNonce]); // eslint-disable-line
const [kids, setKids] = useState<FsEntry[] | null>(null);
@ -2477,12 +2666,13 @@ function FileTree({ scope, onReveal, onFlash, onOpenFile, initialSide, sideNonce
<button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
</div>
<span className="ft-total">{rootLabel} · <b>{humanSize(total)}</b></span>
{onNewFolder && <button className="ft-new" title={t("New folder / project…")} onClick={() => onNewFolder(side)}>{I.folder}<span>{t("New")}</span></button>}
</div>
<div className="exp-tree">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Weighing…")}</div>}
{!busy && err && <div className="ur-empty">{err}</div>}
{!busy && kids && kids.length === 0 && !err && <div className="ur-empty">{t("Empty.")}</div>}
{!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} onOpen={onOpenFile ? (n) => onOpenFile(n, side) : undefined} />)}
{!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} onOpen={onOpenFile ? (n) => onOpenFile(n, side) : undefined} onMenu={onFolderMenu} />)}
</div>
</>
);
@ -3033,29 +3223,35 @@ function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { histo
/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
const thumbCache = new Map<string, string | null>(); // depotFile -> objectURL | null(no thumb)
// image formats the webview can actually decode in an <img>. Game/DCC formats
// (tga, dds, exr, psd, tiff, hdr…) can't — those get the glyph, not a broken img.
const IMG_RENDERABLE = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "bmp"]);
function Thumb({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const kind = kindOf(name);
const ext = (name.split(".").pop() || "").toLowerCase();
const [url, setUrl] = useState<string | null>(() => thumbCache.get(dp) ?? null);
useEffect(() => {
if (kind !== "image" && kind !== "uasset") return;
const renderableImage = kind === "image" && IMG_RENDERABLE.has(ext);
if (!renderableImage && kind !== "uasset") return;
if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
let live = true;
// .uasset → backend pulls just the embedded thumbnail (no huge IPC transfer);
// real images → fetch the file bytes and blob them directly
const job = kind === "uasset"
? p4.uassetThumb(dp, false).then((buf) => URL.createObjectURL(new Blob([buf], { type: "image/png" })))
: p4.readDepot(dp).then((buf) => {
const ext = (name.split(".").pop() || "png").toLowerCase();
return URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }));
});
: p4.readDepot(dp).then((buf) =>
URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }))
);
job.then((u) => { thumbCache.set(dp, u); if (live) setUrl(u); })
.catch(() => { thumbCache.set(dp, null); });
return () => { live = false; };
}, [dp]);
if (url) return <img className="fthumb" src={url} alt="" />;
// onError: some formats (or corrupt files) still fail to decode after all —
// fall back to the glyph instead of the browser's broken-image icon
if (url) return <img className="fthumb" src={url} alt="" onError={() => { thumbCache.set(dp, null); setUrl(null); }} />;
const ic = kind === "model" ? "◆" : kind === "image" || kind === "uasset" ? "▦" : "≡";
return <span className={"fthumb ico k-" + kind}>{ic}</span>;
}

View File

@ -148,6 +148,11 @@ const D: Record<string, Tr> = {
"Save": { ru: "Сохранить", de: "Speichern", fr: "Enregistrer", es: "Guardar" },
"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" },
"found {n} files": { ru: "найдено {n} файлов", de: "{n} Dateien gefunden", fr: "{n} fichiers trouvés", es: "{n} archivos encontrados" },
"Committing…": { ru: "Коммичу…", de: "Übertrage…", fr: "Validation…", es: "Confirmando…" },
"Cancelling…": { ru: "Отменяю…", de: "Breche ab…", fr: "Annulation…", es: "Cancelando…" },
"Stop before the server commit. Files already sent are not applied; the changelist stays pending.": { ru: "Остановить до серверного коммита. Уже отправленные файлы не применяются; changelist останется в pending.", de: "Vor dem Server-Commit stoppen. Bereits gesendete Dateien werden nicht übernommen; die Changelist bleibt ausstehend.", fr: "Arrêter avant le commit serveur. Les fichiers déjà envoyés ne sont pas appliqués ; la changelist reste en attente.", es: "Detener antes del commit del servidor. Los archivos ya enviados no se aplican; la changelist queda pendiente." },
"Stop the scan. Files already found stay staged.": { ru: "Остановить скан. Уже найденные файлы останутся помеченными.", de: "Scan stoppen. Bereits gefundene Dateien bleiben vorgemerkt.", fr: "Arrêter lanalyse. Les fichiers déjà trouvés restent préparés.", es: "Detener el escaneo. Los archivos ya encontrados quedan preparados." },
"{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" },
@ -539,6 +544,7 @@ const D: Record<string, Tr> = {
"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." },
"Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild.": { ru: "Скомпилировать C++ игры — Unreal-проекты собираются через UnrealBuildTool (только модуль игры), обычный C++ — через MSBuild.", de: "Game-C++ kompilieren — Unreal-Projekte über UnrealBuildTool (nur Spielmodul), reines C++ über MSBuild.", fr: "Compiler le C++ du jeu — les projets Unreal via UnrealBuildTool (module de jeu seulement), le C++ simple via MSBuild.", es: "Compilar el C++ del juego — los proyectos Unreal vía UnrealBuildTool (solo el módulo del juego), C++ simple vía MSBuild." },
"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." },
@ -575,6 +581,34 @@ const D: Record<string, Tr> = {
"Duration": { ru: "Длительность", de: "Dauer", fr: "Durée", es: "Duración" },
"server revision": { ru: "ревизия с сервера", de: "Server-Revision", fr: "révision serveur", es: "revisión del servidor" },
"Everyone's checked-out / exclusively-locked files, docked into the view panel.": { ru: "Все чекнутые / эксклюзивно залоченные файлы всех участников, встроенные в панель просмотра.", de: "Alle ausgecheckten / exklusiv gesperrten Dateien aller Nutzer, angedockt im Ansichts-Panel.", fr: "Les fichiers extraits / verrouillés en exclusivité de tout le monde, ancrés dans le panneau.", es: "Los archivos extraídos / bloqueados en exclusiva de todos, anclados en el panel." },
// ---- new folder / project ----
"New": { ru: "Создать", de: "Neu", fr: "Nouveau", es: "Nuevo" },
"workspace root": { ru: "корень воркспейса", de: "Arbeitsbereich-Root", fr: "racine de l'espace de travail", es: "raíz del espacio de trabajo" },
"New folder / project…": { ru: "Новая папка / проект…", de: "Neuer Ordner / Projekt…", fr: "Nouveau dossier / projet…", es: "Nueva carpeta / proyecto…" },
"Create a new folder in the working area (with a README placeholder) — e.g. a separate project.": { ru: "Создать новую папку в рабочей области (с плейсхолдером README) — например, отдельный проект.", de: "Einen neuen Ordner im Arbeitsbereich erstellen (mit README-Platzhalter) — z. B. ein separates Projekt.", fr: "Créer un nouveau dossier dans l'espace de travail (avec un README) — p. ex. un projet distinct.", es: "Crear una nueva carpeta en el área de trabajo (con un README) — p. ej. un proyecto aparte." },
"New folder in {where}": { ru: "Новая папка в {where}", de: "Neuer Ordner in {where}", fr: "Nouveau dossier dans {where}", es: "Nueva carpeta en {where}" },
"Folder name — nest with / (e.g. NewGame or NewGame/Source)": { ru: "Имя папки — вложенность через / (напр. NewGame или NewGame/Source)", de: "Ordnername — verschachteln mit / (z. B. NewGame oder NewGame/Source)", fr: "Nom du dossier — imbriquer avec / (p. ex. NewGame ou NewGame/Source)", es: "Nombre de la carpeta — anida con / (p. ej. NewGame o NewGame/Source)" },
"Created “{name}” with a README placeholder — commit it to keep the folder on the server.": { ru: "Создано «{name}» с плейсхолдером README — закоммить, чтобы папка сохранилась на сервере.", de: "„{name}“ mit README-Platzhalter erstellt — committe es, damit der Ordner auf dem Server bleibt.", fr: "« {name} » créé avec un README — committe-le pour conserver le dossier sur le serveur.", es: "«{name}» creado con un README — haz commit para conservar la carpeta en el servidor." },
// ---- rename / delete / properties folder ----
"Rename…": { ru: "Переименовать…", de: "Umbenennen…", fr: "Renommer…", es: "Renombrar…" },
"Rename folder": { ru: "Переименовать папку", de: "Ordner umbenennen", fr: "Renommer le dossier", es: "Renombrar carpeta" },
"New name for “{name}”": { ru: "Новое имя для «{name}»", de: "Neuer Name für „{name}“", fr: "Nouveau nom pour « {name} »", es: "Nuevo nombre para «{name}»" },
"Renamed to “{name}” — {n} file(s) moved. Commit to apply.": { ru: "Переименовано в «{name}» — перемещено {n} файл(ов). Закоммить, чтобы применить.", de: "In „{name}“ umbenannt — {n} Datei(en) verschoben. Zum Anwenden committen.", fr: "Renommé en « {name} » — {n} fichier(s) déplacés. Committe pour appliquer.", es: "Renombrado a «{name}» — {n} archivo(s) movidos. Haz commit para aplicar." },
"Delete folder…": { ru: "Удалить папку…", de: "Ordner löschen…", fr: "Supprimer le dossier…", es: "Eliminar carpeta…" },
"Delete folder “{name}”?": { ru: "Удалить папку «{name}»?", de: "Ordner „{name}“ löschen?", fr: "Supprimer le dossier « {name} » ?", es: "¿Eliminar la carpeta «{name}»?" },
"Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away.": { ru: "Все файлы под этой папкой помечаются на удаление. С сервера ничего не уходит до Submit; свежедобавленные файлы убираются сразу.", de: "Jede Datei unter diesem Ordner wird zum Löschen markiert. Nichts verlässt den Server bis zum Submit; frisch hinzugefügte Dateien werden sofort entfernt.", fr: "Chaque fichier de ce dossier est marqué pour suppression. Rien ne quitte le serveur avant le Submit ; les fichiers récemment ajoutés sont retirés immédiatement.", es: "Todos los archivos de esta carpeta se marcan para eliminar. Nada sale del servidor hasta el Submit; los archivos recién añadidos se quitan al instante." },
"Marked {n} file(s) for delete — Submit to remove the folder.": { ru: "Помечено {n} файл(ов) на удаление — Submit уберёт папку.", de: "{n} Datei(en) zum Löschen markiert — Submit entfernt den Ordner.", fr: "{n} fichier(s) marqués pour suppression — Submit retire le dossier.", es: "{n} archivo(s) marcados para eliminar — Submit quita la carpeta." },
"Folder removed.": { ru: "Папка удалена.", de: "Ordner entfernt.", fr: "Dossier supprimé.", es: "Carpeta eliminada." },
"Delete": { ru: "Удалить", de: "Löschen", fr: "Supprimer", es: "Eliminar" },
"Properties": { ru: "Свойства", de: "Eigenschaften", fr: "Propriétés", es: "Propiedades" },
"Folder properties": { ru: "Свойства папки", de: "Ordner-Eigenschaften", fr: "Propriétés du dossier", es: "Propiedades de la carpeta" },
"Files": { ru: "Файлов", de: "Dateien", fr: "Fichiers", es: "Archivos" },
"Total size": { ru: "Общий размер", de: "Gesamtgröße", fr: "Taille totale", es: "Tamaño total" },
"Location": { ru: "Расположение", de: "Speicherort", fr: "Emplacement", es: "Ubicación" },
"depot": { ru: "депо", de: "Depot", fr: "depot", es: "depot" },
"local only": { ru: "только локально", de: "nur lokal", fr: "local uniquement", es: "solo local" },
"Recently": { ru: "Недавние", de: "Zuletzt", fr: "Récents", es: "Recientes" },
"local": { ru: "локально", de: "lokal", fr: "local", es: "local" },
};
export function t(en: string, vars?: Record<string, string | number>): string {

View File

@ -102,12 +102,18 @@ export const p4 = {
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
reconcile: (path: string) => invoke<OpenedFile[]>("p4_reconcile", { path }),
scan: (scope = "") => invoke<OpenedFile[]>("p4_scan", { scope }),
// streaming disk scan: emits `p4-scan` progress events, returns the file count.
// The caller re-reads opened() for the actual list.
scanStream: (scope = "") => invoke<number>("p4_scan_stream", { scope }),
scanCancel: () => invoke<void>("p4_scan_cancel"),
// ask an in-progress submit/sync transfer to stop (safe pre-commit for submit)
transferCancel: () => invoke<void>("p4_transfer_cancel"),
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
buildSln: (path: string, config: string) => invoke<string>("build_sln", { path, config }),
buildSln: (path: string, config: string, uproject = "") => invoke<string>("build_sln", { path, config, uproject }),
launchFile: (path: string) => invoke<void>("launch_file", { path }),
listEditors: () => invoke<Editor[]>("list_editors"),
openInEditor: (depot: string, editor: string) => invoke<void>("open_in_editor", { depot, editor }),
@ -159,6 +165,10 @@ export const p4 = {
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { scope }),
makeFolder: (scope: string, name: string) => invoke<string>("make_folder", { scope, name }),
deleteFolder: (path: string) => invoke<OpenedFile[]>("delete_folder", { path }),
renameFolder: (path: string, newName: string) => invoke<OpenedFile[]>("rename_folder", { path, newName }),
folderInfo: (path: string) => invoke<{ path: string; fileCount: number; size: number }>("folder_info", { path }),
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
@ -185,7 +195,7 @@ export interface Editor { id: string; name: string; exe: string; args: string[]
export function getEditor(): string { try { return localStorage.getItem("exd-editor") || ""; } catch { return ""; } }
export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} }
export interface Dir { dir?: string; [k: string]: unknown }
export interface Dir { dir?: string; local?: boolean; [k: string]: unknown }
// depot path -> short leaf name (last segment)
export function leaf(path?: string): string {
if (!path) return "";
@ -256,6 +266,17 @@ export function saveScope(client: string, path: string) {
export function loadScope(client: string): string {
try { return client ? (localStorage.getItem("exd-scope-" + client) || "") : ""; } catch { return ""; }
}
// last 3 chosen working folders (Recently) — most-recent first, per workspace
export function recentScopes(client: string): string[] {
try { const a = JSON.parse(localStorage.getItem("exd-recent-" + client) || "[]"); return Array.isArray(a) ? a.slice(0, 3) : []; } catch { return []; }
}
export function pushRecentScope(client: string, path: string) {
try {
const cur = recentScopes(client).filter((p) => p !== path);
const next = [path, ...cur].slice(0, 3);
localStorage.setItem("exd-recent-" + client, JSON.stringify(next));
} catch {}
}
// format a unix-epoch string as a short local datetime
export function fmtTime(t?: string): string {