2 Commits

Author SHA1 Message Date
9c24d45bf9 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>
2026-07-08 12:56:31 +03:00
ec8f31c654 Offline Work mode + Reconcile Offline Work dialog, menu tooltips, spinner fix; v0.3.0
- Explicit Work Offline toggle (P4V-style): stops server polling, calm offline banner, persisted
- Reconcile Offline Work dialog: preview adds/edits/deletes with per-file checkboxes, opens picked into a changelist
- Make writable (offline edit) context action; backend p4_reconcile_preview/apply + p4_set_writable
- Tooltips (title) on every top-menu item, 5 languages
- Fix .ldr spinner rendering (inline-block + box-sizing) so it's a clean circle everywhere

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:46:37 +03:00
9 changed files with 548 additions and 20 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "exbyte-depot", "name": "exbyte-depot",
"private": true, "private": true,
"version": "0.2.4", "version": "0.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

2
src-tauri/Cargo.lock generated
View File

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

View File

@ -1,6 +1,6 @@
[package] [package]
name = "exbyte-depot" name = "exbyte-depot"
version = "0.2.4" version = "0.3.0"
description = "Exbyte Depot — native Perforce client by Exbyte Studios" description = "Exbyte Depot — native Perforce client by Exbyte Studios"
authors = ["Exbyte Studios"] authors = ["Exbyte Studios"]
edition = "2021" edition = "2021"

View File

@ -324,6 +324,116 @@ async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result<Vec<Value>,
run_json(&conn, &["dirs", &spec]) 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. /// Pending changelists for the current user that actually contain files.
/// Empty pending changelists (leftovers from a reverted/aborted commit) are /// Empty pending changelists (leftovers from a reverted/aborted commit) are
/// cleaned up so they never show a phantom "ready to Submit" or fail submit. /// cleaned up so they never show a phantom "ready to Submit" or fail submit.
@ -1943,6 +2053,73 @@ async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result<Str
Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg }) Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg })
} }
/// Preview offline work (`p4 reconcile -n -e -a -d`): scan the workspace on disk
/// and report what changed while disconnected — files edited (writable but not
/// opened), added (present locally, unknown to the depot) or deleted (in the
/// depot but missing locally). Non-destructive (`-n`): nothing is opened yet.
/// This is P4V's "Reconcile Offline Work". Returns one object per file with an
/// `action` field (edit / add / delete).
#[tauri::command]
async fn p4_reconcile_preview(state: State<'_, AppState>, scope: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
run_json(&conn, &["reconcile", "-n", "-e", "-a", "-d", &scope_spec(&scope)]).or_else(|e| {
let l = e.to_lowercase();
if l.contains("no file") || l.contains("up-to-date") || l.contains("no such") || l.contains("no differing") || l.contains("- no ") {
Ok(Vec::new())
} else {
Err(e)
}
})
}
/// Apply the offline reconcile for the picked files only (`p4 reconcile -e -a -d
/// file…`): opens the selected edits/adds/deletes into the pending changelist so
/// they can be reviewed and submitted. Paths are the depot paths from the preview.
#[tauri::command]
async fn p4_reconcile_apply(state: State<'_, AppState>, paths: Vec<String>) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
let clean: Vec<String> = paths.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect();
if clean.is_empty() {
return Err("No files selected".into());
}
let mut args: Vec<&str> = vec!["reconcile", "-e", "-a", "-d"];
for p in &clean {
args.push(p.as_str());
}
run_json(&conn, &args).or_else(|e| {
let l = e.to_lowercase();
if l.contains("no file") || l.contains("up-to-date") || l.contains("no differing") {
Ok(Vec::new())
} else {
Err(e)
}
})
}
/// Toggle the read-only attribute on local working files so they can be edited
/// while offline (Perforce syncs files read-only; clearing the flag lets an
/// editor save into them before a checkout is possible). Confined to the
/// workspace root. Reports how many files were changed.
#[tauri::command]
async fn p4_set_writable(state: State<'_, AppState>, paths: Vec<String>, writable: bool) -> Result<String, String> {
let conn = current(&state)?;
let mut done = 0usize;
for p in paths.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
ensure_under_root(&conn, p)?;
let meta = std::fs::metadata(p).map_err(|e| format!("File not found: {e}"))?;
let mut perms = meta.permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(!writable);
std::fs::set_permissions(p, perms).map_err(|e| format!("Cannot change {p}: {e}"))?;
done += 1;
}
Ok(if writable {
format!("{done} file(s) made writable")
} else {
format!("{done} file(s) set read-only")
})
}
/// Full revision history of a single file (`p4 filelog -l`), including /// Full revision history of a single file (`p4 filelog -l`), including
/// per-revision changelist, action, user, time and description. Returns the /// per-revision changelist, action, user, time and description. Returns the
/// tagged object with rev0/change0/action0/… arrays. /// tagged object with rev0/change0/action0/… arrays.
@ -2301,6 +2478,9 @@ pub fn run() {
p4_filelog, p4_filelog,
p4_clean_preview, p4_clean_preview,
p4_clean_apply, p4_clean_apply,
p4_reconcile_preview,
p4_reconcile_apply,
p4_set_writable,
p4_submit_shelved, p4_submit_shelved,
p4_submit_opts, p4_submit_opts,
p4_ignore_read, p4_ignore_read,
@ -2332,6 +2512,8 @@ pub fn run() {
read_ai_key, read_ai_key,
save_ai_key, save_ai_key,
p4_dirs, p4_dirs,
depot_ls,
fs_ls,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Exbyte Depot", "productName": "Exbyte Depot",
"mainBinaryName": "Exbyte Depot", "mainBinaryName": "Exbyte Depot",
"version": "0.2.4", "version": "0.3.0",
"identifier": "com.bonchellon.exbyte-depot", "identifier": "com.bonchellon.exbyte-depot",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@ -560,7 +560,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
display:flex;align-items:center;justify-content:center;gap:9px} display:flex;align-items:center;justify-content:center;gap:9px}
.connect:disabled{opacity:.6;cursor:not-allowed}.connect:not(:disabled):hover{filter:brightness(1.08)}.connect svg{width:16px;height:16px} .connect:disabled{opacity:.6;cursor:not-allowed}.connect:not(:disabled):hover{filter:brightness(1.08)}.connect svg{width:16px;height:16px}
.spin{animation:spin 1s linear infinite} .spin{animation:spin 1s linear infinite}
.ldr{width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite} .ldr{display:inline-block;box-sizing:border-box;vertical-align:middle;width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:0 0 auto}
/* diff view */ /* diff view */
.diff{flex:1;overflow:auto;min-height:0;font-family:var(--mono);font-size:12.5px;line-height:1.85;padding:6px 0} .diff{flex:1;overflow:auto;min-height:0;font-family:var(--mono);font-size:12.5px;line-height:1.85;padding:6px 0}
@ -768,8 +768,19 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.netbar.off{background:rgba(242,99,126,.14);color:var(--del);border-bottom:1px solid rgba(242,99,126,.3)} .netbar.off{background:rgba(242,99,126,.14);color:var(--del);border-bottom:1px solid rgba(242,99,126,.3)}
.netbar.warn{background:rgba(232,176,75,.14);color:var(--edit);border-bottom:1px solid rgba(232,176,75,.3);cursor:pointer} .netbar.warn{background:rgba(232,176,75,.14);color:var(--edit);border-bottom:1px solid rgba(232,176,75,.3);cursor:pointer}
.netbar.warn:hover{background:rgba(232,176,75,.2)} .netbar.warn:hover{background:rgba(232,176,75,.2)}
/* explicit "Work Offline" mode — violet, calmer than the error-red disconnect bar */
.netbar.offmode{background:rgba(124,110,246,.13);color:var(--accent-2);border-bottom:1px solid rgba(124,110,246,.3)}
.netbar-btn{margin-left:auto;border:1px solid currentColor;background:none;color:inherit;font-size:11.5px;font-weight:700; .netbar-btn{margin-left:auto;border:1px solid currentColor;background:none;color:inherit;font-size:11.5px;font-weight:700;
border-radius:7px;padding:4px 12px;cursor:pointer;font-family:var(--font)} border-radius:7px;padding:4px 12px;cursor:pointer;font-family:var(--font)}
.netbar-btn+.netbar-btn{margin-left:0}
.netbar-btn:hover{background:currentColor}
.netbar-btn:hover{color:var(--bg)}
/* reconcile offline-work dialog */
.rec-summary{display:flex;align-items:center;gap:6px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--border-soft)}
.rec-all{margin-left:auto;color:var(--accent-2);font-weight:700;cursor:pointer}
.rec-all:hover{text-decoration:underline}
.rec-row{cursor:pointer}
.rec-row .chk{flex:0 0 16px}
/* "held by someone else" pill on a Changes row */ /* "held by someone else" pill on a Changes row */
.held{display:flex;align-items:center;gap:4px;flex:0 0 auto;font-size:10.5px;font-weight:600;color:var(--muted); .held{display:flex;align-items:center;gap:4px;flex:0 0 auto;font-size:10.5px;font-weight:600;color:var(--muted);
background:var(--chip);border:1px solid var(--border-soft);border-radius:7px;padding:2px 7px;max-width:120px;overflow:hidden} background:var(--chip);border:1px solid var(--border-soft);border-radius:7px;padding:2px 7px;max-width:120px;overflow:hidden}
@ -836,8 +847,37 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
/* ---------------- People & Roles ---------------- */ /* ---------------- People & Roles ---------------- */
.picker.wide{width:720px;max-width:94%;height:560px;max-height:82vh;display:flex;flex-direction:column} .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} .ph-sub{margin-left:8px;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
.picker.wide .ph-sub{margin-right:auto} .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-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-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)} .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 CodeView from "./CodeView";
import UpdateBanner from "./Updater"; import UpdateBanner from "./Updater";
import "./App.css"; 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 { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai"; import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
@ -315,6 +315,8 @@ type ModalState =
| { kind: "streams" } | { kind: "streams" }
| { kind: "jobs" } | { kind: "jobs" }
| { kind: "clean" } | { kind: "clean" }
| { kind: "reconcile" }
| { kind: "explorer" }
| { kind: "ignore" } | { kind: "ignore" }
| { kind: "filelog"; depot: string; name: string } | { kind: "filelog"; depot: string; name: string }
| { kind: "client"; name: string; isNew: boolean } | { kind: "client"; name: string; isNew: boolean }
@ -366,6 +368,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
const [resolveOpen, setResolveOpen] = useState(false); const [resolveOpen, setResolveOpen] = useState(false);
const [offline, setOffline] = useState(false); // lost connection to the server const [offline, setOffline] = useState(false); // lost connection to the server
const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast) const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
const notifPrimed = useRef(false); // don't toast on the very first poll const notifPrimed = useRef(false); // don't toast on the very first poll
const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
@ -644,6 +647,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const tick = async () => { const tick = async () => {
if (workOffline) return; // explicit offline-work mode: don't poll the server
try { try {
const latest = await p4.latestChange(activePath); const latest = await p4.latestChange(activePath);
if (!alive) return; if (!alive) return;
@ -668,7 +672,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
tick(); tick();
return () => { alive = false; clearInterval(id); }; return () => { alive = false; clearInterval(id); };
// eslint-disable-next-line // eslint-disable-next-line
}, [activePath, offline, tab]); }, [activePath, offline, tab, workOffline]);
// drag & drop files/folders onto the window → reconcile them into the changelist // drag & drop files/folders onto the window → reconcile them into the changelist
useEffect(() => { useEffect(() => {
@ -1041,6 +1045,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{ label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) }, { label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) },
{ label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) }, { label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) },
{ label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) }, { label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) },
...(workOffline ? [{ label: t("Make writable (offline edit)"), icon: I.unlock, act: () => makeWritable(targets) }] : []),
...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })), ...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })),
...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []), ...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []),
...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []), ...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
@ -1064,6 +1069,25 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
try { await p4.reopenTo(change, dps); flash(t("Moved {n} file(s) to #{c}.", { n: dps.length, c: change })); await refresh(); } try { await p4.reopenTo(change, dps); flash(t("Moved {n} file(s) to #{c}.", { n: dps.length, c: change })); await refresh(); }
catch (e) { flash(String(e), true); } catch (e) { flash(String(e), true); }
} }
// toggle explicit "Work Offline" mode (P4V-style): stop polling the server;
// edits stay local until reconciled.
function toggleWorkOffline() {
setWorkOffline((v) => {
const nv = !v;
try { localStorage.setItem("exd-workoffline", nv ? "1" : "0"); } catch {}
if (nv) { setOffline(false); flash(t("Working offline — edits stay local. Run Reconcile Offline Work to sync.")); }
else { flash(t("Back online.")); refresh(); }
return nv;
});
}
// clear the read-only bit on selected local files so they can be edited while
// offline (before a checkout). Reconcile picks the changes up later.
async function makeWritable(targets: OpenedFile[]) {
const locals = targets.map((f) => f.clientFile || "").filter(Boolean) as string[];
if (!locals.length) { flash(t("No local path for the selection."), true); return; }
try { await p4.setWritable(locals, true); flash(t("{n} file(s) made writable for offline editing.", { n: locals.length })); }
catch (e) { flash(String(e), true); }
}
// diff a historical revision against the one before it (History preview) // diff a historical revision against the one before it (History preview)
async function showDiffPrev(file: OpenedFile) { async function showDiffPrev(file: OpenedFile) {
const dp = file.depotFile || ""; const dp = file.depotFile || "";
@ -1086,18 +1110,51 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
} catch (e) { flash(String(e), true); } } catch (e) { flash(String(e), true); }
} }
const menus: Record<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; act?: () => void }[]> = { const menus: Record<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; hint?: string; act?: () => void }[]> = {
File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("New workspace…"), icon: I.monitor, act: newWorkspace }, ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }], File: [
Connection: [{ label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }], { label: t("Switch workspace…"), icon: I.monitor, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces },
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Clean workspace…"), icon: I.revert, act: () => setModal({ kind: "clean" }) }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, act: getLatest }, { label: t("Get Revision…"), icon: I.clock, act: promptSyncTo }, { label: t("Commit (local)"), icon: I.check, act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), icon: I.revert, act: revertAll }, { label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }], { label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace },
Window: [ ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, hint: t("Edit the current workspace spec — root folder and depot view mapping."), act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []),
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, act: () => openDock("log") }, { label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, act: () => openDock("terminal") }, { label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect },
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, act: () => openDock("unreal") }] : []),
{ label: t("File Locks…"), icon: I.lock, act: () => setModal({ kind: "locks" }) },
], ],
Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: t("Streams…"), icon: I.branch, act: () => setModal({ kind: "streams" }) }, { label: t("Jobs…"), icon: I.log, act: () => setModal({ kind: "jobs" }) }, { label: t("Edit .p4ignore…"), icon: I.gear, act: () => setModal({ kind: "ignore" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }], Connection: [
Help: [{ label: t("About"), icon: I.info, act: () => setModal({ kind: "about" }) }], { label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() },
{ label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
],
Actions: [
{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, hint: t("Scan the workspace for files changed outside the app (reconcile)."), act: rescan },
{ label: (workOffline ? "✓ " : "") + t("Work Offline"), icon: I.power, hint: t("Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V."), act: toggleWorkOffline },
{ label: t("Reconcile Offline Work…"), icon: I.scan, hint: t("Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them."), act: () => setModal({ kind: "reconcile" }) },
{ label: t("Clean workspace…"), icon: I.revert, hint: t("Make the workspace exactly match the depot — discards un-opened local changes."), act: () => setModal({ kind: "clean" }) },
{ label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, hint: t("Sync the workspace to the newest revision (pull)."), act: getLatest },
{ label: t("Get Revision…"), icon: I.clock, hint: t("Sync the workspace to a specific changelist, label or date."), act: promptSyncTo },
{ label: t("Commit (local)"), icon: I.check, hint: t("Save selected files into a local pending changelist (not sent yet)."), act: doCommit },
{ label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, hint: t("Send pending changelists to the server (push)."), act: pushAll },
...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, hint: t("Resolve merge conflicts left after a sync."), act: () => setResolveOpen(true) }] : []),
{ label: t("Revert All…"), icon: I.revert, hint: t("Discard every open change and restore depot versions."), act: revertAll },
{ label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() },
],
Window: [
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") },
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") },
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, hint: t("Show output from the headless Unreal preview process."), act: () => openDock("unreal") }] : []),
{ label: t("File Locks…"), icon: I.lock, hint: t("See every file currently exclusively locked and by whom."), act: () => setModal({ kind: "locks" }) },
],
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" }) },
{ label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) },
{ label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) },
{ label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the Visual Studio solution found in the working folder."), act: startBuild },
{ label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
{ label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
],
Help: [{ label: t("About"), icon: I.info, hint: t("Version and information about Exbyte Depot."), act: () => setModal({ kind: "about" }) }],
}; };
return ( return (
@ -1110,7 +1167,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{t(name)} {t(name)}
<div className="dropdown"> <div className="dropdown">
{items.map((it) => ( {items.map((it) => (
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}> <div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()} title={it.hint}>
{it.icon && <span className="di-ic">{it.icon}</span>} {it.icon && <span className="di-ic">{it.icon}</span>}
{it.label}{it.kb && <span className="kb">{it.kb}</span>} {it.label}{it.kb && <span className="kb">{it.kb}</span>}
</div> </div>
@ -1164,9 +1221,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div> </div>
</div> </div>
{offline && ( {workOffline ? (
<div className="netbar offmode">
{I.power}{t("Working offline — edits stay on your disk. Reconcile to bring them into a changelist.")}
<button className="netbar-btn" onClick={() => setModal({ kind: "reconcile" })}>{t("Reconcile…")}</button>
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Go online")}</button>
</div>
) : offline && (
<div className="netbar off"> <div className="netbar off">
<span className="ldr sm" />{t("Disconnected from the server — retrying…")} <span className="ldr sm" />{t("Disconnected from the server — retrying…")}
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Work offline instead")}</button>
</div> </div>
)} )}
{needResolve.length > 0 && tab === "changes" && ( {needResolve.length > 0 && tab === "changes" && (
@ -1359,6 +1423,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />} {modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />} {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 === "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 === "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 === "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(); } }} />} {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(); } }} />}
@ -2131,6 +2197,165 @@ function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClos
); );
} }
/* ---------------- reconcile offline work ---------------- */
// P4V-style "Reconcile Offline Work": scan the workspace for changes made while
// disconnected (edits / adds / deletes) and open the picked ones into a changelist.
function ReconcileModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
const [files, setFiles] = useState<OpenedFile[]>([]);
const [busy, setBusy] = useState(true);
const [applying, setApplying] = useState(false);
const [sel, setSel] = useState<Set<string>>(new Set());
const keyOf = (f: OpenedFile) => f.depotFile || f.clientFile || "";
useEffect(() => {
let live = true;
p4.reconcilePreview(scope)
.then((f) => { if (live) { setFiles(f); setSel(new Set(f.map(keyOf).filter(Boolean))); } })
.catch((e) => { if (live) { onFlash(String(e), true); setFiles([]); } })
.finally(() => live && setBusy(false));
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [scope]); // eslint-disable-line
const toggle = (k: string) => setSel((s) => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n; });
const allOn = files.length > 0 && sel.size === files.length;
const toggleAll = () => setSel(allOn ? new Set() : new Set(files.map(keyOf).filter(Boolean)));
async function apply() {
const paths = files.map(keyOf).filter((k) => sel.has(k));
if (!paths.length) return;
setApplying(true);
try {
const opened = await p4.reconcileApply(paths);
onFlash(t("Reconciled {n} file(s) into a changelist.", { n: opened.length || paths.length }));
onDone();
} catch (e) { onFlash(String(e), true); }
finally { setApplying(false); }
}
// group for a readable header count
const counts = { add: 0, edit: 0, delete: 0 } as Record<string, number>;
for (const f of files) { const a = (f.action || "").toLowerCase(); if (a.includes("add")) counts.add++; else if (a.includes("delete")) counts.delete++; else counts.edit++; }
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.scan}<h3>{t("Reconcile Offline Work")}</h3><span className="ph-sub">{scope || "//…"} · {t("{n} changes", { n: files.length })}</span>
<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="tm-hint">{t("These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.")}</div>
{!busy && files.length > 0 && (
<div className="rec-summary">
<span style={{ color: "var(--add)", fontWeight: 700 }}>+{counts.add}</span> {t("added")} · <span style={{ color: "var(--edit)", fontWeight: 700 }}>±{counts.edit}</span> {t("modified")} · <span style={{ color: "var(--del)", fontWeight: 700 }}>{counts.delete}</span> {t("deleted")}
<span className="rec-all" onClick={toggleAll}>{allOn ? t("Deselect all") : t("Select all")}</span>
</div>
)}
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning workspace for offline changes…")}</div>}
{!busy && files.length === 0 && <div className="ur-empty">{t("Nothing to reconcile — the workspace matches what the server knows.")}</div>}
{files.map((f) => {
const dp = keyOf(f);
const sp = splitPath(dp);
const st = statusOf(f.action);
const on = sel.has(dp);
return (
<div key={dp} className="srch-row rec-row" onClick={() => toggle(dp)}>
<span className={"chk" + (on ? "" : " off")}>{on ? I.check : null}</span>
<span className={"stat " + st.cls} style={{ marginRight: 4 }}>{st.ch}</span>
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
<span className="lk-who">{f.action}</span>
</div>
);
})}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={applying}>{t("Cancel")}</button>
<button className="mbtn" onClick={apply} disabled={applying || busy || sel.size === 0}>{applying ? <span className="ldr sm" /> : null}{t("Reconcile {n} file(s)", { n: sel.size })}</button>
</div>
</div>
</div>
);
}
/* ---------------- 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) ---------------- */ /* ---------------- 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 }) { 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[]>([]); const [revs, setRevs] = useState<FileRev[]>([]);

View File

@ -489,6 +489,71 @@ const D: Record<string, Tr> = {
".p4ignore saved.": { ru: ".p4ignore сохранён.", de: ".p4ignore gespeichert.", fr: ".p4ignore enregistré.", es: ".p4ignore guardado." }, ".p4ignore saved.": { ru: ".p4ignore сохранён.", de: ".p4ignore gespeichert.", fr: ".p4ignore enregistré.", es: ".p4ignore guardado." },
"Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." }, "Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." },
"Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" }, "Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" },
// ---- offline work + reconcile ----
"Work Offline": { ru: "Работать офлайн", de: "Offline arbeiten", fr: "Travailler hors ligne", es: "Trabajar sin conexión" },
"Reconcile Offline Work…": { ru: "Согласовать офлайн-правки…", de: "Offline-Arbeit abgleichen…", fr: "Réconcilier le travail hors ligne…", es: "Reconciliar trabajo sin conexión…" },
"Reconcile Offline Work": { ru: "Согласование офлайн-правок", de: "Offline-Arbeit abgleichen", fr: "Réconcilier le travail hors ligne", es: "Reconciliar trabajo sin conexión" },
"Working offline — edits stay local. Run Reconcile Offline Work to sync.": { ru: "Работаешь офлайн — правки остаются локально. Запусти «Согласовать офлайн-правки», чтобы синхронизировать.", de: "Offline-Modus — Änderungen bleiben lokal. Führe „Offline-Arbeit abgleichen“ aus, um zu synchronisieren.", fr: "Hors ligne — les modifications restent locales. Lance « Réconcilier le travail hors ligne » pour synchroniser.", es: "Sin conexión — los cambios quedan en local. Ejecuta «Reconciliar trabajo sin conexión» para sincronizar." },
"Back online.": { ru: "Снова онлайн.", de: "Wieder online.", fr: "De nouveau en ligne.", es: "De nuevo en línea." },
"Working offline — edits stay on your disk. Reconcile to bring them into a changelist.": { ru: "Работаешь офлайн — правки лежат на диске. Согласуй, чтобы собрать их в changelist.", de: "Offline — Änderungen liegen auf deiner Festplatte. Gleiche ab, um sie in eine Changelist zu übernehmen.", fr: "Hors ligne — les modifications sont sur votre disque. Réconciliez pour les regrouper dans une changelist.", es: "Sin conexión — los cambios están en tu disco. Reconcilia para llevarlos a una changelist." },
"Reconcile…": { ru: "Согласовать…", de: "Abgleichen…", fr: "Réconcilier…", es: "Reconciliar…" },
"Go online": { ru: "Выйти онлайн", de: "Online gehen", fr: "Passer en ligne", es: "Conectar" },
"Work offline instead": { ru: "Перейти в офлайн", de: "Stattdessen offline arbeiten", fr: "Travailler hors ligne", es: "Trabajar sin conexión" },
"Make writable (offline edit)": { ru: "Сделать редактируемым (офлайн)", de: "Beschreibbar machen (offline)", fr: "Rendre modifiable (hors ligne)", es: "Hacer editable (sin conexión)" },
"No local path for the selection.": { ru: "Нет локального пути для выделения.", de: "Kein lokaler Pfad für die Auswahl.", fr: "Aucun chemin local pour la sélection.", es: "No hay ruta local para la selección." },
"{n} file(s) made writable for offline editing.": { ru: "{n} файл(ов) сделаны редактируемыми для офлайн-правки.", de: "{n} Datei(en) für Offline-Bearbeitung beschreibbar gemacht.", fr: "{n} fichier(s) rendus modifiables pour l'édition hors ligne.", es: "{n} archivo(s) hechos editables para edición sin conexión." },
"These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.": { ru: "Эти файлы изменились на диске без ведома сервера. Выбери, что внести в pending changelist: добавленные → p4 add, изменённые → p4 edit, пропавшие → p4 delete. Ничего не сабмитится — сначала проверишь и отправишь сам.", de: "Diese Dateien haben sich auf der Festplatte geändert, ohne dass der Server es weiß. Wähle, was in eine offene Changelist soll: neue → p4 add, geänderte → p4 edit, fehlende → p4 delete. Nichts wird übermittelt — du prüfst und übermittelst danach.", fr: "Ces fichiers ont changé sur le disque à l'insu du serveur. Choisis ceux à placer dans une changelist en attente : ajoutés → p4 add, modifiés → p4 edit, manquants → p4 delete. Rien n'est soumis — tu vérifies et soumets ensuite.", es: "Estos archivos cambiaron en disco sin que el servidor lo supiera. Elige cuáles llevar a una changelist pendiente: añadidos → p4 add, modificados → p4 edit, faltantes → p4 delete. Nada se envía — revisas y envías después." },
"added": { ru: "добавлено", de: "hinzugefügt", fr: "ajoutés", es: "añadidos" },
"modified": { ru: "изменено", de: "geändert", fr: "modifiés", es: "modificados" },
"deleted": { ru: "удалено", de: "gelöscht", fr: "supprimés", es: "eliminados" },
"Scanning workspace for offline changes…": { ru: "Сканирую воркспейс на офлайн-изменения…", de: "Arbeitsbereich wird auf Offline-Änderungen geprüft…", fr: "Analyse de l'espace de travail pour les modifications hors ligne…", es: "Analizando el espacio de trabajo en busca de cambios sin conexión…" },
"Nothing to reconcile — the workspace matches what the server knows.": { ru: "Согласовывать нечего — воркспейс совпадает с тем, что знает сервер.", de: "Nichts abzugleichen — der Arbeitsbereich stimmt mit dem Server überein.", fr: "Rien à réconcilier — l'espace de travail correspond à ce que le serveur connaît.", es: "Nada que reconciliar — el espacio de trabajo coincide con lo que el servidor conoce." },
"Reconcile {n} file(s)": { ru: "Согласовать {n} файл(ов)", de: "{n} Datei(en) abgleichen", fr: "Réconcilier {n} fichier(s)", es: "Reconciliar {n} archivo(s)" },
"Reconciled {n} file(s) into a changelist.": { ru: "Согласовано {n} файл(ов) в changelist.", de: "{n} Datei(en) in eine Changelist abgeglichen.", fr: "{n} fichier(s) réconciliés dans une changelist.", es: "{n} archivo(s) reconciliados en una changelist." },
// ---- menu tooltips ----
"Change which Perforce workspace (client) you're working in.": { ru: "Сменить воркспейс (client), в котором ты работаешь.", de: "Wechsle den Perforce-Arbeitsbereich (Client), in dem du arbeitest.", fr: "Change l'espace de travail Perforce (client) dans lequel tu travailles.", es: "Cambia el espacio de trabajo de Perforce (client) en el que trabajas." },
"Create a new workspace mapping depot folders to a local folder.": { ru: "Создать новый воркспейс, сопоставив папки депо с локальной папкой.", de: "Erstelle einen neuen Arbeitsbereich, der Depot-Ordner einem lokalen Ordner zuordnet.", fr: "Crée un espace de travail associant des dossiers du depot à un dossier local.", es: "Crea un espacio de trabajo que asigna carpetas del depot a una carpeta local." },
"Edit the current workspace spec — root folder and depot view mapping.": { ru: "Изменить спеку текущего воркспейса — корневую папку и маппинг депо.", de: "Bearbeite die aktuelle Arbeitsbereich-Spezifikation — Root-Ordner und Depot-View-Mapping.", fr: "Modifie la spec de l'espace de travail actuel — dossier racine et mappage du depot.", es: "Edita la spec del espacio de trabajo actual: carpeta raíz y mapeo del depot." },
"Pick which depot folder the app shows and syncs.": { ru: "Выбрать, какую папку депо приложение показывает и синхронизирует.", de: "Wähle, welchen Depot-Ordner die App anzeigt und synchronisiert.", fr: "Choisis quel dossier du depot l'app affiche et synchronise.", es: "Elige qué carpeta del depot muestra y sincroniza la app." },
"Sign out and return to the connection screen.": { ru: "Выйти и вернуться к экрану подключения.", de: "Abmelden und zum Verbindungsbildschirm zurückkehren.", fr: "Se déconnecter et revenir à l'écran de connexion.", es: "Cerrar sesión y volver a la pantalla de conexión." },
"Re-read state from the server.": { ru: "Перечитать состояние с сервера.", de: "Zustand vom Server neu einlesen.", fr: "Relire l'état depuis le serveur.", es: "Volver a leer el estado desde el servidor." },
"Scan the workspace for files changed outside the app (reconcile).": { ru: "Просканировать воркспейс на файлы, изменённые вне приложения (reconcile).", de: "Arbeitsbereich nach außerhalb der App geänderten Dateien durchsuchen (reconcile).", fr: "Analyser l'espace de travail pour les fichiers modifiés hors de l'app (reconcile).", es: "Analiza el espacio de trabajo en busca de archivos cambiados fuera de la app (reconcile)." },
"Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V.": { ru: "Включить/выключить офлайн-режим: не опрашивать сервер, править файлы локально и согласовать позже. Как в P4V.", de: "Offline-Modus umschalten: Server nicht abfragen, Dateien lokal bearbeiten und später abgleichen. Wie in P4V.", fr: "Basculer le mode hors ligne : ne plus interroger le serveur, éditer localement et réconcilier plus tard. Comme dans P4V.", es: "Alternar el modo sin conexión: dejar de consultar el servidor, editar en local y reconciliar después. Como en P4V." },
"Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them.": { ru: "Догнать сервер работой, сделанной офлайн: открывает локальные add / edit / delete в changelist для отправки.", de: "Den Server mit offline erledigter Arbeit nachziehen: öffnet lokale Adds / Edits / Deletes in einer Changelist zum Übermitteln.", fr: "Mettre le serveur à jour avec le travail hors ligne : ouvre les ajouts / modifications / suppressions locaux dans une changelist à soumettre.", es: "Poner al día el servidor con el trabajo sin conexión: abre adds / edits / deletes locales en una changelist para enviarlos." },
"Make the workspace exactly match the depot — discards un-opened local changes.": { ru: "Привести воркспейс точно к состоянию депо — отбрасывает неоткрытые локальные правки.", de: "Arbeitsbereich exakt an das Depot angleichen — verwirft nicht geöffnete lokale Änderungen.", fr: "Aligner exactement l'espace de travail sur le depot — abandonne les modifications locales non ouvertes.", es: "Hacer que el espacio de trabajo coincida exactamente con el depot — descarta cambios locales no abiertos." },
"Sync the workspace to the newest revision (pull).": { ru: "Синхронизировать воркспейс до последней ревизии (pull).", de: "Arbeitsbereich auf die neueste Revision synchronisieren (Pull).", fr: "Synchroniser l'espace de travail sur la dernière révision (pull).", es: "Sincronizar el espacio de trabajo a la revisión más reciente (pull)." },
"Sync the workspace to a specific changelist, label or date.": { ru: "Синхронизировать воркспейс до конкретного changelist, метки или даты.", de: "Arbeitsbereich auf eine bestimmte Changelist, ein Label oder ein Datum synchronisieren.", fr: "Synchroniser l'espace de travail sur une changelist, un label ou une date précis.", es: "Sincronizar el espacio de trabajo a una changelist, etiqueta o fecha concretas." },
"Save selected files into a local pending changelist (not sent yet).": { ru: "Сохранить выбранные файлы в локальный pending changelist (пока не отправлено).", de: "Ausgewählte Dateien in eine lokale offene Changelist speichern (noch nicht gesendet).", fr: "Enregistrer les fichiers sélectionnés dans une changelist locale en attente (pas encore envoyée).", es: "Guardar los archivos seleccionados en una changelist local pendiente (aún no enviada)." },
"Send pending changelists to the server (push).": { ru: "Отправить pending changelists на сервер (push).", de: "Offene Changelists an den Server senden (Push).", fr: "Envoyer les changelists en attente au serveur (push).", es: "Enviar las changelists pendientes al servidor (push)." },
"Resolve merge conflicts left after a sync.": { ru: "Разрешить конфликты слияния, оставшиеся после синхронизации.", de: "Nach einer Synchronisierung verbliebene Merge-Konflikte auflösen.", fr: "Résoudre les conflits de fusion restants après une synchronisation.", es: "Resolver los conflictos de fusión que quedan tras una sincronización." },
"Discard every open change and restore depot versions.": { ru: "Отменить все открытые правки и восстановить версии из депо.", de: "Alle offenen Änderungen verwerfen und Depot-Versionen wiederherstellen.", fr: "Abandonner toutes les modifications ouvertes et restaurer les versions du depot.", es: "Descartar todos los cambios abiertos y restaurar las versiones del depot." },
"Show the panel of recently run Perforce commands.": { ru: "Показать панель недавно выполненных команд Perforce.", de: "Zeige das Panel der zuletzt ausgeführten Perforce-Befehle.", fr: "Afficher le panneau des commandes Perforce récemment exécutées.", es: "Mostrar el panel de comandos de Perforce ejecutados recientemente." },
"Open an embedded terminal for raw p4 commands.": { ru: "Открыть встроенный терминал для сырых команд p4.", de: "Ein eingebettetes Terminal für rohe p4-Befehle öffnen.", fr: "Ouvrir un terminal intégré pour les commandes p4 brutes.", es: "Abrir una terminal integrada para comandos p4 en crudo." },
"Show output from the headless Unreal preview process.": { ru: "Показать вывод фонового процесса Unreal-превью.", de: "Ausgabe des Headless-Unreal-Vorschauprozesses anzeigen.", fr: "Afficher la sortie du processus de prévisualisation Unreal sans interface.", es: "Mostrar la salida del proceso de vista previa de Unreal sin interfaz." },
"See every file currently exclusively locked and by whom.": { ru: "Посмотреть все файлы, сейчас взятые на эксклюзивный лок, и кем.", de: "Alle aktuell exklusiv gesperrten Dateien und von wem sehen.", fr: "Voir tous les fichiers actuellement verrouillés en exclusivité et par qui.", es: "Ver todos los archivos bloqueados en exclusiva ahora mismo y por quién." },
"Find files anywhere in the depot by name or path.": { ru: "Найти файлы в любом месте депо по имени или пути.", de: "Dateien überall im Depot nach Name oder Pfad finden.", fr: "Trouver des fichiers n'importe où dans le depot par nom ou chemin.", es: "Buscar archivos en cualquier lugar del depot por nombre o ruta." },
"Set which binary asset types are exclusive-checkout (+l) so only one person edits them.": { ru: "Задать, какие бинарные типы ассетов берутся эксклюзивно (+l), чтобы их правил только один человек.", de: "Festlegen, welche binären Asset-Typen exklusiv ausgecheckt werden (+l), damit nur eine Person sie bearbeitet.", fr: "Définir quels types d'assets binaires sont en extraction exclusive (+l) pour qu'une seule personne les édite.", es: "Definir qué tipos de assets binarios son de extracción exclusiva (+l) para que solo una persona los edite." },
"Named snapshots of file revisions: tag files, or sync to a label.": { ru: "Именованные снимки ревизий файлов: пометить файлы или синхронизироваться на метку.", de: "Benannte Schnappschüsse von Dateirevisionen: Dateien taggen oder auf ein Label synchronisieren.", fr: "Instantanés nommés de révisions de fichiers : taguer des fichiers ou synchroniser sur un label.", es: "Instantáneas con nombre de revisiones de archivos: etiquetar archivos o sincronizar a una etiqueta." },
"Move changes between branches of the depot.": { ru: "Переносить изменения между ветками депо.", de: "Änderungen zwischen Depot-Branches verschieben.", fr: "Déplacer des modifications entre les branches du depot.", es: "Mover cambios entre ramas del depot." },
"Switch stream, merge down from the parent, or copy up to it.": { ru: "Переключить стрим, влить сверху от родителя (merge down) или протолкнуть наверх (copy up).", de: "Stream wechseln, vom Parent mergen (merge down) oder nach oben kopieren (copy up).", fr: "Changer de stream, fusionner depuis le parent (merge down) ou remonter (copy up).", es: "Cambiar de stream, fusionar desde el padre (merge down) o copiar hacia arriba (copy up)." },
"Perforce's task/bug tracker — create jobs and attach them to changelists.": { ru: "Трекер задач/багов Perforce — создавай джобы и привязывай к changelists.", de: "Perforce-Aufgaben-/Bug-Tracker — Jobs erstellen und an Changelists anhängen.", fr: "Le suivi de tâches/bugs de Perforce — crée des jobs et attache-les aux changelists.", es: "El rastreador de tareas/errores de Perforce — crea jobs y adjúntalos a changelists." },
"Edit which files reconcile / add ignore (build output, caches).": { ru: "Изменить, какие файлы игнорируют reconcile / add (сборка, кэши).", de: "Bearbeiten, welche Dateien reconcile / add ignorieren (Build-Ausgabe, Caches).", fr: "Modifier quels fichiers reconcile / add ignorent (sortie de build, caches).", es: "Editar qué archivos ignoran reconcile / add (salida de compilación, cachés)." },
"Compile the Visual Studio solution found in the working folder.": { ru: "Скомпилировать решение Visual Studio, найденное в рабочей папке.", de: "Die im Arbeitsordner gefundene Visual-Studio-Solution kompilieren.", fr: "Compiler la solution Visual Studio trouvée dans le dossier de travail.", es: "Compilar la solución de Visual Studio encontrada en la carpeta de trabajo." },
"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 { export function t(en: string, vars?: Record<string, string | number>): string {

View File

@ -74,6 +74,11 @@ export const p4 = {
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }), filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }), cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { 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 }),
jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"), jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"),
fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }), fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }),
jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }), jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }),
@ -188,6 +193,17 @@ export function leaf(path?: string): string {
// per-file revision history parsed from p4 filelog's indexed fields // 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 } 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[] { export function filelogRevs(v: Record<string, unknown>): FileRev[] {
const out: FileRev[] = []; const out: FileRev[] = [];
for (let i = 0; ; i++) { for (let i = 0; ; i++) {