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:
312
src/App.tsx
312
src/App.tsx
@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user