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:
Bonchellon
2026-07-08 12:56:31 +03:00
parent ec8f31c654
commit 9c24d45bf9
6 changed files with 254 additions and 2 deletions

2
src-tauri/Cargo.lock generated
View File

@ -960,7 +960,7 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.2.4"
version = "0.3.0"
dependencies = [
"encoding_rs",
"serde",

View File

@ -324,6 +324,116 @@ async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result<Vec<Value>,
run_json(&conn, &["dirs", &spec])
}
/// Browse a depot folder AND weigh it: returns the immediate children of `path`
/// (empty = depot roots) — each sub-directory with its recursive file count and
/// total byte size (`p4 sizes -s`), and each file directly inside with its size
/// (`p4 sizes`). Powers the Depot side of the file/size explorer. Children are
/// returned unsorted; the UI sorts by size.
#[tauri::command]
async fn depot_ls(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
let base = path.trim_end_matches(['/', '\\']).to_string();
let mut out: Vec<Value> = Vec::new();
// 1) sub-directories
let dir_spec = if base.is_empty() { "//*".to_string() } else { format!("{}/*", base) };
let dirs = run_json(&conn, &["dirs", &dir_spec]).unwrap_or_default();
let dir_paths: Vec<String> = dirs
.iter()
.filter_map(|d| d.get("dir").and_then(|s| s.as_str()).map(|s| s.to_string()))
.collect();
// 2) recursive size summary for every sub-directory in one call
// (`p4 sizes -s //a/... //b/... …` → one summary line per argument)
if !dir_paths.is_empty() {
let mut args: Vec<String> = vec!["sizes".into(), "-s".into()];
for d in &dir_paths {
args.push(format!("{}/...", d));
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let summaries = run_json(&conn, &arg_refs).unwrap_or_default();
// map "//a/..." → (fileCount, fileSize)
let mut by_path: std::collections::HashMap<String, (u64, u64)> = std::collections::HashMap::new();
for s in &summaries {
let p = s.get("path").and_then(|x| x.as_str()).unwrap_or("").trim_end_matches("/...").trim_end_matches("...").trim_end_matches('/').to_string();
let fc = s.get("fileCount").and_then(|x| x.as_str()).and_then(|x| x.parse::<u64>().ok()).unwrap_or(0);
let fsz = s.get("fileSize").and_then(|x| x.as_str()).and_then(|x| x.parse::<u64>().ok()).unwrap_or(0);
if !p.is_empty() { by_path.insert(p, (fc, fsz)); }
}
for d in &dir_paths {
let key = d.trim_end_matches('/').to_string();
let (fc, fsz) = by_path.get(&key).copied().unwrap_or((0, 0));
let name = key.rsplit('/').next().unwrap_or(&key).to_string();
out.push(serde_json::json!({ "name": name, "path": key, "isDir": true, "size": fsz, "fileCount": fc }));
}
}
// 3) files directly inside this folder (non-recursive) with their sizes
if !base.is_empty() {
let file_spec = format!("{}/*", base);
let files = run_json(&conn, &["sizes", &file_spec]).unwrap_or_default();
for f in &files {
let dp = f.get("depotFile").and_then(|x| x.as_str()).unwrap_or("").to_string();
if dp.is_empty() { continue; }
let sz = f.get("fileSize").and_then(|x| x.as_str()).and_then(|x| x.parse::<u64>().ok()).unwrap_or(0);
let name = dp.rsplit('/').next().unwrap_or(&dp).to_string();
out.push(serde_json::json!({ "name": name, "path": dp, "isDir": false, "size": sz, "fileCount": 1 }));
}
}
Ok(out)
}
/// Recursively sum the byte size of a local directory tree, ignoring
/// unreadable entries. Used by the Workspace side of the explorer.
fn dir_size_of(p: &std::path::Path) -> u64 {
let mut total = 0u64;
if let Ok(rd) = std::fs::read_dir(p) {
for e in rd.flatten() {
match e.file_type() {
Ok(ft) if ft.is_dir() => total += dir_size_of(&e.path()),
Ok(ft) if ft.is_file() => total += e.metadata().map(|m| m.len()).unwrap_or(0),
_ => {}
}
}
}
total
}
/// Browse a local (Workspace) folder AND weigh it: immediate children of `path`
/// (empty = the workspace root), each sub-folder with its recursive byte size,
/// each file with its size. Confined to the workspace root.
#[tauri::command]
async fn fs_ls(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
let dir = if path.trim().is_empty() {
if conn.root.is_empty() { return Err("No workspace root for this connection".into()); }
conn.root.clone()
} else {
path.clone()
};
ensure_under_root(&conn, &dir)?;
let mut out: Vec<Value> = Vec::new();
let rd = std::fs::read_dir(&dir).map_err(|e| format!("Cannot read folder: {e}"))?;
for e in rd.flatten() {
let p = e.path();
let name = e.file_name().to_string_lossy().to_string();
let path_str = p.to_string_lossy().to_string();
match e.file_type() {
Ok(ft) if ft.is_dir() => {
let sz = dir_size_of(&p);
out.push(serde_json::json!({ "name": name, "path": path_str, "isDir": true, "size": sz, "fileCount": 0 }));
}
Ok(ft) if ft.is_file() => {
let sz = e.metadata().map(|m| m.len()).unwrap_or(0);
out.push(serde_json::json!({ "name": name, "path": path_str, "isDir": false, "size": sz, "fileCount": 1 }));
}
_ => {}
}
}
Ok(out)
}
/// Pending changelists for the current user that actually contain files.
/// Empty pending changelists (leftovers from a reverted/aborted commit) are
/// cleaned up so they never show a phantom "ready to Submit" or fail submit.
@ -2402,6 +2512,8 @@ pub fn run() {
read_ai_key,
save_ai_key,
p4_dirs,
depot_ls,
fs_ls,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -847,8 +847,37 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
/* ---------------- People & Roles ---------------- */
.picker.wide{width:720px;max-width:94%;height:560px;max-height:82vh;display:flex;flex-direction:column}
.picker.wide.tall{height:660px}
.ph-sub{margin-left:8px;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
.picker.wide .ph-sub{margin-right:auto}
/* ---- Depot & Workspace file/size explorer ---- */
.exp-tabs{display:flex;gap:4px;margin-left:14px;background:var(--sunk);padding:3px;border-radius:9px}
.exp-tab{border:none;background:none;color:var(--muted);font-size:12px;font-weight:600;padding:5px 14px;border-radius:7px;cursor:pointer;font-family:var(--font)}
.exp-tab:hover{color:var(--txt)}
.exp-tab.on{background:var(--accent);color:#fff}
.exp-total{display:flex;align-items:center;padding:10px 18px;border-bottom:1px solid var(--border-soft)}
.exp-total .n{font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.exp-total .sz{margin-left:auto;font-size:13px;font-weight:700;color:var(--accent-2);font-variant-numeric:tabular-nums;white-space:nowrap;padding-left:12px}
.exp-tree{flex:1;overflow-y:auto;padding:6px 8px 12px}
.exp-node{display:flex;flex-direction:column}
.exp-row{display:flex;align-items:center;gap:7px;padding:5px 10px;border-radius:8px;cursor:default;transition:background .1s}
.exp-row.dir{cursor:pointer}
.exp-row:hover{background:var(--hover)}
.exp-row:hover .exp-open{opacity:1}
.exp-caret{width:14px;flex:0 0 14px;display:grid;place-items:center;color:var(--faint)}
.chevi{display:grid;place-items:center;transition:transform .12s}
.chevi svg{width:13px;height:13px}
.chevi.open{transform:rotate(90deg)}
.exp-ic{width:17px;height:17px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
.exp-ic svg{width:16px;height:16px}
.exp-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:60px}
.exp-count{font-size:10.5px;color:var(--faint);white-space:nowrap;flex:0 0 auto}
.exp-bar{margin-left:auto;flex:0 0 110px;height:6px;border-radius:4px;background:var(--sunk);overflow:hidden}
.exp-bar > span{display:block;height:100%;background:linear-gradient(90deg,var(--accent),var(--accent-2));border-radius:4px}
.exp-size{flex:0 0 68px;text-align:right;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums}
.exp-open{flex:0 0 auto;width:22px;height:22px;display:grid;place-items:center;color:var(--faint);cursor:pointer;opacity:0;border-radius:6px;transition:opacity .1s,color .1s}
.exp-open svg{width:14px;height:14px}
.exp-open:hover{color:var(--accent-2);background:var(--panel)}
.ur-body{flex:1;display:flex;min-height:0}
.ur-list{flex:0 0 300px;display:flex;flex-direction:column;border-right:1px solid var(--border-soft);min-height:0}
.ur-filter{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border-soft)}

View File

@ -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[]>([]);

View File

@ -542,6 +542,18 @@ const D: Record<string, Tr> = {
"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." },
// ---- files & sizes explorer ----
"Files & Sizes…": { ru: "Файлы и размеры…", de: "Dateien & Größen…", fr: "Fichiers et tailles…", es: "Archivos y tamaños…" },
"Files & Sizes": { ru: "Файлы и размеры", de: "Dateien & Größen", fr: "Fichiers et tailles", es: "Archivos y tamaños" },
"Browse the Depot and your Workspace as a tree and see how much every file and folder weighs.": { ru: "Просматривай Depot и свой Workspace деревом и смотри, сколько весит каждый файл и папка.", de: "Durchsuche das Depot und deinen Arbeitsbereich als Baum und sieh, wie viel jede Datei und jeder Ordner wiegt.", fr: "Parcours le Depot et ton espace de travail sous forme d'arbre et vois le poids de chaque fichier et dossier.", es: "Explora el Depot y tu espacio de trabajo como un árbol y ve cuánto pesa cada archivo y carpeta." },
"Depot": { ru: "Depot", de: "Depot", fr: "Depot", es: "Depot" },
"Whole depot": { ru: "Весь depot", de: "Gesamtes Depot", fr: "Depot entier", es: "Todo el depot" },
"Workspace root": { ru: "Корень workspace", de: "Arbeitsbereich-Root", fr: "Racine de l'espace de travail", es: "Raíz del espacio de trabajo" },
"{n} items": { ru: "{n} эл.", de: "{n} Einträge", fr: "{n} éléments", es: "{n} elementos" },
"Weighing…": { ru: "Считаю вес…", de: "Wird gewogen…", fr: "Calcul du poids…", es: "Calculando tamaño…" },
"Empty.": { ru: "Пусто.", de: "Leer.", fr: "Vide.", es: "Vacío." },
"Sizes come from the server (p4 sizes): the total bytes each depot folder stores across all its files. Sorted heaviest first.": { ru: "Размеры берутся с сервера (p4 sizes): суммарный объём, который каждая папка депо хранит по всем файлам. Сортировка по убыванию веса.", de: "Größen kommen vom Server (p4 sizes): die Gesamtbytes, die jeder Depot-Ordner über alle Dateien speichert. Nach Größe absteigend sortiert.", fr: "Les tailles viennent du serveur (p4 sizes) : le total d'octets que chaque dossier du depot stocke sur tous ses fichiers. Trié du plus lourd au plus léger.", es: "Los tamaños vienen del servidor (p4 sizes): el total de bytes que cada carpeta del depot almacena en todos sus archivos. Ordenado de mayor a menor." },
"Sizes are measured on your disk: how much each folder and file of your local workspace actually takes up. Sorted heaviest first.": { ru: "Размеры измеряются на твоём диске: сколько реально занимает каждая папка и файл локального workspace. Сортировка по убыванию веса.", de: "Größen werden auf deiner Festplatte gemessen: wie viel jeder Ordner und jede Datei deines lokalen Arbeitsbereichs tatsächlich belegt. Nach Größe absteigend sortiert.", fr: "Les tailles sont mesurées sur ton disque : l'espace réellement occupé par chaque dossier et fichier de ton espace de travail local. Trié du plus lourd au plus léger.", es: "Los tamaños se miden en tu disco: cuánto ocupa realmente cada carpeta y archivo de tu espacio de trabajo local. Ordenado de mayor a menor." },
};
export function t(en: string, vars?: Record<string, string | number>): string {

View File

@ -74,6 +74,8 @@ export const p4 = {
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { scope }),
depotLs: (path = "") => invoke<FsEntry[]>("depot_ls", { path }),
fsLs: (path = "") => invoke<FsEntry[]>("fs_ls", { path }),
reconcilePreview: (scope = "") => invoke<OpenedFile[]>("p4_reconcile_preview", { scope }),
reconcileApply: (paths: string[]) => invoke<OpenedFile[]>("p4_reconcile_apply", { paths }),
setWritable: (paths: string[], writable: boolean) => invoke<string>("p4_set_writable", { paths, writable }),
@ -191,6 +193,17 @@ export function leaf(path?: string): string {
// per-file revision history parsed from p4 filelog's indexed fields
export interface FileRev { rev: string; change: string; action: string; user: string; time: string; desc: string; type: string }
/** One entry (file or folder) in the Depot/Workspace size explorer. `size` is bytes. */
export interface FsEntry { name: string; path: string; isDir: boolean; size: number; fileCount: number }
/** Human-readable byte size, e.g. 1536 → "1.5 KB". */
export function humanSize(n: number): string {
if (!n) return "0 B";
if (n < 1024) return `${n} B`;
const u = ["KB", "MB", "GB", "TB", "PB"];
let v = n, i = -1;
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
}
export function filelogRevs(v: Record<string, unknown>): FileRev[] {
const out: FileRev[] = [];
for (let i = 0; ; i++) {