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

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");