Files & Sizes explorer: browse Depot & Workspace as a tree with per-file/folder weight
- Backend depot_ls (p4 dirs + p4 sizes -s recursive summary per child + p4 sizes for direct files) and fs_ls (local recursive folder sizing), both confined - Tools -> Files & Sizes: Depot/Workspace tabs, lazy-loading tree, size bars, heaviest-first sort, folder file counts, open-in-explorer - humanSize() helper + FsEntry type; i18n in 5 languages Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
88
src/App.tsx
88
src/App.tsx
@ -7,7 +7,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, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev } from "./p4";
|
||||
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 { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
|
||||
|
||||
@ -316,6 +316,7 @@ type ModalState =
|
||||
| { kind: "jobs" }
|
||||
| { kind: "clean" }
|
||||
| { kind: "reconcile" }
|
||||
| { kind: "explorer" }
|
||||
| { kind: "ignore" }
|
||||
| { kind: "filelog"; depot: string; name: string }
|
||||
| { kind: "client"; name: string; isNew: boolean }
|
||||
@ -1142,6 +1143,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
],
|
||||
Tools: [
|
||||
{ label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) },
|
||||
{ label: t("Files & Sizes…"), icon: I.folder, hint: t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs."), act: () => setModal({ kind: "explorer" }) },
|
||||
{ label: t("Exclusive Locks (typemap)…"), icon: I.lock, hint: t("Set which binary asset types are exclusive-checkout (+l) so only one person edits them."), act: () => setModal({ kind: "typemap" }) },
|
||||
{ label: t("Labels…"), icon: I.clock, hint: t("Named snapshots of file revisions: tag files, or sync to a label."), act: () => setModal({ kind: "labels" }) },
|
||||
{ label: t("Integrate / Merge / Copy…"), icon: I.branch, hint: t("Move changes between branches of the depot."), act: () => setModal({ kind: "branch" }) },
|
||||
@ -1422,6 +1424,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||
{modal?.kind === "reconcile" && <ReconcileModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||
{modal?.kind === "explorer" && <ExplorerModal scope={activePath} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} />}
|
||||
{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(); } }} />}
|
||||
@ -2270,6 +2273,89 @@ function ReconcileModal({ scope, onClose, onFlash, onDone }: { scope: string; on
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Depot & Workspace file/size explorer ---------------- */
|
||||
const FILE_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.5" /><path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.5" /></svg>;
|
||||
const sortBySize = (a: FsEntry[]) => [...a].sort((x, y) => (y.size - x.size) || Number(y.isDir) - Number(x.isDir) || x.name.localeCompare(y.name));
|
||||
|
||||
// one row in the tree; folders lazily load their children on first expand.
|
||||
function ExpNode({ node, depth, load, max, onReveal, onFlash }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function toggle() {
|
||||
if (!node.isDir) return;
|
||||
if (!open && kids === null) {
|
||||
setBusy(true);
|
||||
try { setKids(sortBySize(await load(node.path))); }
|
||||
catch (e) { onFlash(String(e), true); setKids([]); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
const pct = Math.max(2, Math.round((node.size / max) * 100));
|
||||
const childMax = Math.max(1, ...(kids || []).map((k) => k.size));
|
||||
return (
|
||||
<div className="exp-node">
|
||||
<div className={"exp-row" + (node.isDir ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={toggle} 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>
|
||||
{node.isDir && node.fileCount > 0 ? <span className="exp-count">{t("{n} files", { n: node.fileCount })}</span> : null}
|
||||
<span className="exp-bar"><span style={{ width: pct + "%" }} /></span>
|
||||
<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} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; onClose: () => void; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [side, setSide] = useState<"depot" | "workspace">("depot");
|
||||
const [kids, setKids] = useState<FsEntry[] | null>(null);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
const load = (path: string) => (side === "depot" ? p4.depotLs(path) : p4.fsLs(path));
|
||||
const rootPath = side === "depot" ? scope : "";
|
||||
const rootLabel = side === "depot" ? (scope || t("Whole depot")) : t("Workspace root");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setBusy(true); setKids(null); setErr("");
|
||||
load(rootPath)
|
||||
.then((k) => { if (live) setKids(sortBySize(k)); })
|
||||
.catch((e) => { if (live) { setErr(String(e)); setKids([]); } })
|
||||
.finally(() => live && setBusy(false));
|
||||
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { live = false; document.removeEventListener("keydown", k); };
|
||||
}, [side]); // eslint-disable-line
|
||||
const total = (kids || []).reduce((a, b) => a + b.size, 0);
|
||||
const max = Math.max(1, ...(kids || []).map((k) => k.size));
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide tall" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.folder}<h3>{t("Files & Sizes")}</h3>
|
||||
<div className="exp-tabs">
|
||||
<button className={"exp-tab" + (side === "depot" ? " on" : "")} onClick={() => setSide("depot")}>{t("Depot")}</button>
|
||||
<button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
|
||||
</div>
|
||||
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||
</div>
|
||||
<div className="exp-total"><span className="n">{rootLabel}</span><span className="sz">{humanSize(total)}{kids ? ` · ${t("{n} items", { n: kids.length })}` : ""}</span></div>
|
||||
<div className="tm-hint">{side === "depot"
|
||||
? t("Sizes come from the server (p4 sizes): the total bytes each depot folder stores across all its files. Sorted heaviest first.")
|
||||
: t("Sizes are measured on your disk: how much each folder and file of your local workspace actually takes up. Sorted heaviest first.")}</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} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- file history timeline (filelog) ---------------- */
|
||||
function FilelogModal({ depot, name, onClose, onFlash, onShowDiff }: { depot: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onShowDiff: (title: string, text: string) => void }) {
|
||||
const [revs, setRevs] = useState<FileRev[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user