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:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -960,7 +960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.4"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serde",
|
||||
|
||||
@ -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");
|
||||
|
||||
Reference in New Issue
Block a user