diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index abe4ed1..835b525 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -960,7 +960,7 @@ dependencies = [ [[package]] name = "exbyte-depot" -version = "0.2.4" +version = "0.3.0" dependencies = [ "encoding_rs", "serde", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8863d8c..aceb737 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -324,6 +324,116 @@ async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result, 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, String> { + let conn = current(&state)?; + let base = path.trim_end_matches(['/', '\\']).to_string(); + let mut out: Vec = 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 = 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 = 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 = 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::().ok()).unwrap_or(0); + let fsz = s.get("fileSize").and_then(|x| x.as_str()).and_then(|x| x.parse::().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::().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, 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 = 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"); diff --git a/src/App.css b/src/App.css index a0caa56..ebf81b7 100644 --- a/src/App.css +++ b/src/App.css @@ -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)} diff --git a/src/App.tsx b/src/App.tsx index bfc9152..f98d1c9 100644 --- a/src/App.tsx +++ b/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" && setModal(null)} onFlash={flash} />} {modal?.kind === "clean" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} {modal?.kind === "reconcile" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} + {modal?.kind === "explorer" && setModal(null)} onReveal={reveal} onFlash={flash} />} {modal?.kind === "ignore" && setModal(null)} onFlash={flash} />} {modal?.kind === "filelog" && setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />} {modal?.kind === "client" && 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 = ; +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; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) { + const [open, setOpen] = useState(false); + const [kids, setKids] = useState(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 ( +
+
+ {node.isDir ? (busy ? : {I.chevron}) : null} + {node.isDir ? I.folder : FILE_GLYPH} + {node.name} + {node.isDir && node.fileCount > 0 ? {t("{n} files", { n: node.fileCount })} : null} + + {humanSize(node.size)} + { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder} +
+ {open && kids && kids.map((k) => )} +
+ ); +} + +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(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 ( +
+
e.stopPropagation()}> +
{I.folder}

{t("Files & Sizes")}

+
+ + +
+ +
+
{rootLabel}{humanSize(total)}{kids ? ` · ${t("{n} items", { n: kids.length })}` : ""}
+
{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.")}
+
+ {busy &&
{t("Weighing…")}
} + {!busy && err &&
{err}
} + {!busy && kids && kids.length === 0 && !err &&
{t("Empty.")}
} + {!busy && kids && kids.map((k) => )} +
+
+
+ ); +} + /* ---------------- 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([]); diff --git a/src/i18n.ts b/src/i18n.ts index bf39c7a..d3a67ca 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -542,6 +542,18 @@ const D: Record = { "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 { diff --git a/src/p4.ts b/src/p4.ts index c461af6..25cb273 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -74,6 +74,8 @@ export const p4 = { filelog: (depot: string) => invoke>("p4_filelog", { depot }), cleanPreview: (scope = "") => invoke("p4_clean_preview", { scope }), cleanApply: (scope = "") => invoke("p4_clean_apply", { scope }), + depotLs: (path = "") => invoke("depot_ls", { path }), + fsLs: (path = "") => invoke("fs_ls", { path }), reconcilePreview: (scope = "") => invoke("p4_reconcile_preview", { scope }), reconcileApply: (paths: string[]) => invoke("p4_reconcile_apply", { paths }), setWritable: (paths: string[], writable: boolean) => invoke("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): FileRev[] { const out: FileRev[] = []; for (let i = 0; ; i++) {