diff --git a/package-lock.json b/package-lock.json index 9c0302a..af9ea3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "exbyte-depot", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "exbyte-depot", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", @@ -1456,6 +1457,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", diff --git a/package.json b/package.json index 7159c36..67d03d3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.3.0", + "version": "0.3.1", "type": "module", "scripts": { "dev": "vite", @@ -12,6 +12,7 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 835b525..28bc348 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -343,6 +343,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.0" @@ -968,6 +974,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-updater", @@ -1733,6 +1740,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2022,6 +2042,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2081,6 +2115,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -2132,6 +2176,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2634,6 +2692,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2702,6 +2769,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.41.0" @@ -2732,6 +2805,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3593,6 +3695,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni 0.21.1", "libc", "log", @@ -3747,6 +3850,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -3912,6 +4034,17 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5299,6 +5432,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 26bbb8c..c35e6b0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exbyte-depot" -version = "0.3.0" +version = "0.3.1" description = "Exbyte Depot — native Perforce client by Exbyte Studios" authors = ["Exbyte Studios"] edition = "2021" @@ -18,8 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-opener = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-dialog = "2.7.1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bccfc88..5ae6763 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,8 +11,13 @@ "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-close", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-unminimize", "dialog:default", "updater:default", - "process:default" + "process:default", + "notification:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aceb737..84515ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2024,10 +2024,37 @@ async fn p4_reopen_to(state: State<'_, AppState>, change: String, files: Vec, scope: String) -> Result { let conn = current(&state)?; let spec = if scope.is_empty() { "//...".to_string() } else { scope_spec(&scope) }; - let v = run_json(&conn, &["changes", "-s", "submitted", "-m", "1", &spec])?; + // -l → full description so notifications can show what the teammate wrote + let v = run_json(&conn, &["changes", "-s", "submitted", "-m", "1", "-l", &spec])?; Ok(v.into_iter().next().unwrap_or(Value::Null)) } +/// How far the workspace is behind the server: previews `p4 sync -n` and counts +/// the files that would update (added / updated / deleted), plus a few sample +/// names. `{count, sample}`; count 0 = up to date. Non-destructive. +#[tauri::command] +async fn p4_behind(state: State<'_, AppState>, scope: String) -> Result { + let conn = current(&state)?; + let spec = scope_spec(&scope); + match run_json(&conn, &["sync", "-n", &spec]) { + Ok(v) => { + let files: Vec<&Value> = v.iter().filter(|o| o.get("depotFile").is_some() || o.get("clientFile").is_some()).collect(); + let sample: Vec = files.iter().take(6).filter_map(|o| { + o.get("depotFile").or_else(|| o.get("clientFile")).and_then(|s| s.as_str()).map(|s| s.rsplit(['/', '\\']).next().unwrap_or(s).to_string()) + }).collect(); + Ok(serde_json::json!({ "count": files.len(), "sample": sample })) + } + Err(e) => { + let l = e.to_lowercase(); + if l.contains("up-to-date") || l.contains("no such file") || l.contains("no such area") || l.contains("not in client") { + Ok(serde_json::json!({ "count": 0, "sample": [] })) + } else { + Err(e) + } + } + } +} + /// Preview `p4 clean` — files that would be deleted (local extras), restored /// (locally deleted) or refreshed (locally modified but not opened) to make the /// workspace exactly match the depot. Non-destructive (`-n`). @@ -2053,6 +2080,26 @@ async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result, changes: Vec, scope: String) -> Result, String> { + let conn = current(&state)?; + let root = if scope.trim().is_empty() { "//".to_string() } else { format!("{}/", scope.trim_end_matches(['/', '\\'])) }; + let mut out: Vec = Vec::new(); + for ch in changes.iter().map(|c| c.trim()).filter(|c| !c.is_empty()) { + let spec = format!("{}...@={}", root, ch); + let size = run_json(&conn, &["sizes", "-s", &spec]) + .ok() + .and_then(|v| v.into_iter().next()) + .and_then(|o| o.get("fileSize").and_then(|s| s.as_str()).and_then(|s| s.parse::().ok())) + .unwrap_or(0); + out.push(serde_json::json!({ "change": ch, "size": size })); + } + Ok(out) +} + /// 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 @@ -2411,6 +2458,7 @@ async fn save_ai_key(key: String) -> Result<(), String> { pub fn run() { let mut builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_dialog::init()); #[cfg(desktop)] { @@ -2419,9 +2467,56 @@ pub fn run() { .plugin(tauri_plugin_process::init()); } builder + // clicking the window's X hides to the tray instead of quitting, so + // notifications keep arriving. Real quit is the tray menu's "Quit". + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + use tauri::Emitter; + if window.label() == "main" { + let _ = window.hide(); + let _ = window.emit("tray-hidden", ()); + api.prevent_close(); + } + } + }) .setup(|app| { + use tauri::menu::{Menu, MenuItem}; + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + use tauri::Manager; // stash the app handle so p4 helpers can emit the live command log let _ = APP.set(app.handle().clone()); + + // ---- system tray: keeps the app alive & notifying when the window is closed ---- + let show = MenuItem::with_id(app, "show", "Open Exbyte Depot", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit Exbyte Depot", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &quit])?; + let _tray = TrayIconBuilder::with_id("main-tray") + .icon(app.default_window_icon().unwrap().clone()) + .tooltip("Exbyte Depot — Perforce") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } + } + "quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { + let app = tray.app_handle(); + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } + } + }) + .build(app)?; Ok(()) }) .manage(AppState::default()) @@ -2514,6 +2609,8 @@ pub fn run() { p4_dirs, depot_ls, fs_ls, + p4_change_sizes, + p4_behind, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ab8274d..a0e8e33 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Exbyte Depot", "mainBinaryName": "Exbyte Depot", - "version": "0.3.0", + "version": "0.3.1", "identifier": "com.bonchellon.exbyte-depot", "build": { "beforeDevCommand": "npm run dev", @@ -24,8 +24,8 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'", - "devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'", + "devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" } }, "bundle": { diff --git a/src/App.css b/src/App.css index ebf81b7..f8fc209 100644 --- a/src/App.css +++ b/src/App.css @@ -489,6 +489,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .summary::placeholder{color:var(--faint)} .desc{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:9px 11px;font-size:12.5px;color:var(--txt);min-height:46px;resize:none;outline:none;font-family:var(--font)} .desc::placeholder{color:var(--faint)} +.summary:focus,.desc:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.14)} .submit{border:none;border-radius:10px;padding:11px;font-size:13.5px;font-weight:600;color:#fff;cursor:pointer; background:linear-gradient(135deg,var(--accent-2),var(--accent-deep)); box-shadow:0 8px 24px -6px rgba(124,110,246,.6),0 0 0 1px rgba(255,255,255,.08) inset; @@ -527,6 +528,15 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .stage .lbl2{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:var(--chip); border:1px solid var(--border);border-radius:20px;padding:5px 11px;display:flex;align-items:center;gap:7px} .stage .hint{position:absolute;bottom:14px;right:14px;font-size:11px;color:var(--faint)} +/* audio / video preview */ +.mediaplayer{width:min(460px,86%)} +.mediaplayer.vid{width:auto;max-width:92%;max-height:88%;border-radius:12px;box-shadow:0 24px 60px -24px #000;background:#000} +.audiobox{display:flex;flex-direction:column;align-items:center;gap:16px;width:min(460px,86%)} +.audio-art{width:104px;height:104px;border-radius:22px;display:grid;place-items:center;color:#fff; + background:linear-gradient(140deg,var(--accent),var(--accent-deep));box-shadow:0 20px 46px -18px var(--accent)} +.audio-art svg{width:48px;height:48px} +.audio-name{font-size:14px;font-weight:600;color:var(--txt);text-align:center;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 10px} +.audiobox audio{width:100%} .assetmeta{border-top:1px solid var(--border);padding:15px 20px;display:grid;grid-template-columns:repeat(4,1fr);gap:13px 22px;background:var(--sunk)} .mi{display:flex;flex-direction:column;gap:3px} .mi .k{font-size:10.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.6px} @@ -599,10 +609,22 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .ce-btn.save{color:#fff;border:none;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))} .ce-btn.save:hover:not(:disabled){filter:brightness(1.1)} .ce-btn svg{width:13px;height:13px} +/* spec editors (client / job / .p4ignore): a clean editor surface, not a flat grey box */ .code-edit{flex:1;min-height:0;width:100%;resize:none;border:none;outline:none; font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;color:var(--txt); - background:var(--stage-bg);padding:12px 16px;white-space:pre;overflow:auto} + background:var(--elevated);padding:12px 16px 14px 18px;white-space:pre;overflow:auto; + box-shadow:inset 3px 0 0 var(--border-soft)} .code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)} +/* rich code editor: transparent textarea over a highlighted underlay + gutter */ +.code.editing{overflow:hidden} +.code.editing .code-gutter{overflow:hidden} +.ce-wrap{position:relative;flex:1;min-width:0;background:transparent} +.ce-under,.ce-ta{margin:0;padding:10px 16px;border:0;font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;white-space:pre;word-wrap:normal;letter-spacing:normal} +.ce-under{position:absolute;inset:0;overflow:hidden;pointer-events:none;background:none;color:var(--txt)} +.ce-under code{font-family:inherit;background:none;padding:0;white-space:pre} +.ce-ta{position:absolute;inset:0;width:100%;height:100%;resize:none;outline:none;overflow:auto;background:transparent;color:transparent;caret-color:var(--accent-2)} +.ce-ta::selection{background:rgba(124,110,246,.28);color:transparent} +.ce-ta::-moz-selection{background:rgba(124,110,246,.28);color:transparent} /* highlight.js tokens mapped to the app palette (theme-aware) */ .hljs{color:var(--txt);background:none} @@ -642,10 +664,29 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .histsplit>.histh{position:relative;flex:none;width:8px;align-self:stretch;cursor:col-resize;background:transparent;top:auto;bottom:auto} .histsplit .histlist .crow-go{opacity:0} .histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)} +/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */ +.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden} +.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto} +.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px} +.vtab:hover{color:var(--txt);background:var(--hover)} +.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)} +.vtab-ic{display:grid;place-items:center;color:var(--accent-2)} +.vtab-ic svg{width:15px;height:15px} +.vtab-x{margin-left:4px;width:17px;height:17px;display:grid;place-items:center;border-radius:5px;font-size:15px;line-height:1;color:var(--faint)} +.vtab-x:hover{background:var(--del);color:#fff} +.viewbody{flex:1;display:flex;min-height:0;min-width:0;position:relative} +.vpane{flex:1;min-width:0;min-height:0} +.vpane>.right,.vpane>.histsplit{flex:1 1 auto;min-width:0;min-height:0} +.vpane.tree{flex-direction:column} +.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto} +.ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums} +.ft-total b{color:var(--accent-2)} +.vpane.tree .exp-tree{flex:1;min-height:0} .hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px} .hbody{display:flex;flex-direction:column;gap:3px;min-width:0} .hdesc{font-size:13px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical} .hmeta{font-size:11px;color:var(--faint)} +.hsize{margin-left:7px;padding:1px 6px;border-radius:6px;background:var(--chip);border:1px solid var(--border-soft);color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap} /* 3d viewer toolbar */ .v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2} @@ -770,6 +811,10 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .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)} +/* "you're behind the server" — informational blue, clickable to Get Latest */ +.netbar.behind{background:rgba(90,160,255,.14);color:#4f8fe0;border-bottom:1px solid rgba(90,160,255,.32);cursor:pointer} +.netbar.behind:hover{background:rgba(90,160,255,.2)} +.behind-names{font-weight:500;opacity:.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} .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)} .netbar-btn+.netbar-btn{margin-left:0} diff --git a/src/App.tsx b/src/App.tsx index f98d1c9..b9a0929 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { listen } from "@tauri-apps/api/event"; +import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification"; import ModelViewer from "./ModelViewer"; import CodeView from "./CodeView"; import UpdateBanner from "./Updater"; @@ -11,6 +12,18 @@ import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, fi import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n"; import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai"; +// native OS notification (works even when the window is hidden in the tray) +let notifyReady: boolean | null = null; +async function notify(title: string, body: string) { + try { + if (notifyReady === null) { + notifyReady = await isPermissionGranted(); + if (!notifyReady) notifyReady = (await requestPermission()) === "granted"; + } + if (notifyReady) sendNotification({ title, body }); + } catch { /* notifications unavailable — the in-app toast still shows */ } +} + /* ---------------- window controls (frameless) ---------------- */ function WinControls() { const w = getCurrentWindow(); @@ -302,6 +315,7 @@ type Tab = "changes" | "history"; type CtxItem = { label: string; danger?: boolean; icon?: ReactNode; act: () => void }; +type ViewTab = "viewer" | "tree" | "locks"; // dockable panels in the right view area type ModalState = | { kind: "workspaces"; items: Client[]; current: string } | { kind: "scope"; path: string; dirs: Dir[] } @@ -351,6 +365,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [openMenu, setOpenMenu] = useState(null); const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null); const [history, setHistory] = useState([]); + const [changeSizes, setChangeSizes] = useState>({}); // per-changelist byte weight for History const [detail, setDetail] = useState(null); const [selChange, setSelChange] = useState(""); // highlighted History row (instant) const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading @@ -369,6 +384,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [resolveOpen, setResolveOpen] = useState(false); 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 [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace const lastSubmit = useRef(""); // newest submitted CL seen (new-submit toast) const notifPrimed = useRef(false); // don't toast on the very first poll const [buildLog, setBuildLog] = useState([]); // live MSBuild output @@ -383,6 +399,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set // bottom dock: a tabbed panel (Log / Terminal / Unreal), opened from Window menu const [dockOpen, setDockOpen] = useState(false); const [dockTab, setDockTab] = useState<"log" | "terminal" | "unreal">("log"); + // right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab. + const [viewTabs, setViewTabs] = useState(() => { + try { const a = JSON.parse(localStorage.getItem("exd-viewtabs") || ""); if (Array.isArray(a) && a.length && a.every((x: string) => x === "viewer" || x === "tree" || x === "locks")) return a; } catch {} + return ["viewer"]; + }); + const [viewTab, setViewTab] = useState("viewer"); + const [treeSide, setTreeSide] = useState<"depot" | "workspace">("depot"); // which side the embedded File Tree shows + const [treeNonce, setTreeNonce] = useState(0); // bump to force the tree to (re)focus a side const [dockH, setDockH] = useState(() => { const v = Number(localStorage.getItem("exd-dock-h")); return v >= 120 ? v : 240; }); // dock panel height useEffect(() => { try { localStorage.setItem("exd-dock-h", String(Math.round(dockH))); } catch {} }, [dockH]); const [termLines, setTermLines] = useState([]); // terminal output blocks @@ -657,11 +681,15 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set if (!notifPrimed.current) { lastSubmit.current = n; notifPrimed.current = true; } else if (n !== lastSubmit.current && Number(n) > Number(lastSubmit.current || 0)) { const who = latest?.user || "someone"; - if ((who || "").toLowerCase() !== (info?.userName || "").toLowerCase()) { + const mine = (who || "").toLowerCase() === (info?.userName || "").toLowerCase(); + if (!mine) { + const desc = (latest?.desc || "").trim().replace(/\s+/g, " ").slice(0, 160) || t("(no description)"); flash(t("{user} submitted #{n}", { user: who, n })); + notify(t("{user} submitted #{n}", { user: who, n }), desc); // native toast — arrives even from the tray } lastSubmit.current = n; if (tab === "history") loadHistory(); + checkBehind(); // a new submit landed → am I now behind? } } } catch { @@ -674,6 +702,22 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set // eslint-disable-next-line }, [activePath, offline, tab, workOffline]); + // remember which view-panel tabs are open across restarts + useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]); + + // check "am I behind the server" when connected or the working folder changes + useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]); + + // window was closed to the tray → tell the user once per session it's still alive + useEffect(() => { + let shown = false; + const un = listen("tray-hidden", () => { + if (shown) return; shown = true; + notify(t("Exbyte Depot is still running"), t("It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit.")); + }); + return () => { un.then((f) => f()); }; + }, []); + // drag & drop files/folders onto the window → reconcile them into the changelist useEffect(() => { let unlisten: (() => void) | undefined; @@ -766,8 +810,20 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const f = scope !== undefined ? scope : activePath; const path = f ? f + "/..." : ""; setBusy(true); - try { setHistory(await p4.history(path)); } - catch (e) { flash(String(e), true); } finally { setBusy(false); } + try { + const h = await p4.history(path); + setHistory(h); + // commit weights load in the background so the list shows instantly + const nums = h.map((c) => c.change || "").filter(Boolean); + setChangeSizes({}); + if (nums.length) { + p4.changeSizes(nums, f).then((rows) => { + const m: Record = {}; + for (const r of rows) m[r.change] = r.size; + setChangeSizes(m); + }).catch(() => {}); + } + } catch (e) { flash(String(e), true); } finally { setBusy(false); } } function switchTab(t: Tab) { setTab(t); @@ -777,9 +833,24 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set async function getLatest() { setBusy(true); - try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); } // sync may pull new submitted changelists → refresh History too + try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too catch (e) { flash(String(e), true); setBusy(false); } } + // check whether the server has newer content than my workspace (files behind) + const behindRef = useRef(0); + async function checkBehind() { + if (workOffline) return; + try { + const b = await p4.behind(activePath); + const was = behindRef.current; + behindRef.current = b.count; + setBehind(b.count > 0 ? b : null); + // notify only when we *newly* fall behind (not on every recheck) + if (b.count > 0 && was === 0) { + notify(t("Your workspace is behind"), t("{n} file(s) on the server are newer than your copy. Get Latest to update.", { n: b.count })); + } + } catch { /* ignore — non-critical */ } + } // sync the workspace to an exact changelist / label / date (Get Revision) async function syncTo(rev: string) { const r = rev.trim(); @@ -1080,6 +1151,25 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set return nv; }); } + // right-panel view tabs: open/focus, close, and Window-menu toggle + function focusViewTab(k: ViewTab) { setViewTabs((ts) => (ts.includes(k) ? ts : [...ts, k])); setViewTab(k); } + function closeViewTab(k: ViewTab) { + setViewTabs((ts) => { const nt = ts.filter((x) => x !== k); if (viewTab === k) setViewTab((nt[nt.length - 1] as ViewTab) || "viewer"); return nt; }); + } + function toggleViewTab(k: ViewTab) { if (viewTabs.includes(k)) closeViewTab(k); else focusViewTab(k); } + // open the File Tree view-tab focused on a given side (Depot or Workspace) + function showInTree(side: "depot" | "workspace") { setTreeSide(side); setTreeNonce((n) => n + 1); focusViewTab("tree"); } + // open a file picked in the File Tree: depot files preview in the File Viewer, + // local files open in the OS file browser. + function openTreeFile(n: FsEntry, side: "depot" | "workspace") { + if (side === "depot") { + if (tab !== "changes") switchTab("changes"); + setPreviewCL({ depotFile: n.path, _spec: n.path, action: "browse", rev: "", type: "", change: "" } as unknown as OpenedFile); + focusViewTab("viewer"); + } else { + reveal(n.path); + } + } // 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[]) { @@ -1136,10 +1226,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set { label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() }, ], Window: [ + { label: (viewTabs.includes("viewer") ? "✓ " : "") + t("File Viewer"), icon: FILE_GLYPH, hint: t("The preview panel for the selected file (image, 3D model, code, diff)."), act: () => toggleViewTab("viewer") }, + { label: (viewTabs.includes("tree") ? "✓ " : "") + t("File Tree"), icon: I.folder, hint: t("A folder hierarchy of the Depot and your Workspace, docked into the view panel."), act: () => toggleViewTab("tree") }, + { label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") }, { 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" }) }, @@ -1185,6 +1277,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
openCtx(e, [ + { label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("workspace") }, { label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) }, { label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) }, @@ -1197,6 +1290,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
startResize(e, "sync")} title={t("Drag to resize")} />
browseTo("")} title={t("Choose the project working folder · right-click for more")} onContextMenu={(e) => openCtx(e, [ + { label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") }, ...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []), ...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []), { label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) }, @@ -1239,6 +1333,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
)} + {behind && behind.count > 0 && !workOffline && ( +
+ {I.sync}{t("Server has newer content — {n} file(s) behind.", { n: behind.count })} + {behind.sample.length > 0 ? {behind.sample.slice(0, 3).join(" · ")}{behind.count > 3 ? " …" : ""} : null} + +
+ )}
startResize(e, "left")} title={t("Drag to resize")} />
@@ -1345,7 +1446,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
) : ( - openCtx(e, [ { label: t("Open this changelist"), act: () => openChange(c) }, { label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") }, @@ -1356,28 +1457,58 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set )}
- {tab === "changes" ? ( - setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} /> - ) : ( -
- - {histFile && (<> -
{ - e.preventDefault(); - const startX = e.clientX, start = histW; - document.body.classList.add("resizing"); - const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620))); - const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); }; - document.addEventListener("pointermove", move); document.addEventListener("pointerup", up); - // mouse fallback in case pointer events don't fire in the webview - document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up); - window.addEventListener("blur", up); // released outside the window → end drag - }} /> - setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} /> - )} +
+ {viewTabs.length > 1 && ( +
+ {viewTabs.map((k) => ( +
setViewTab(k)}> + {k === "viewer" ? FILE_GLYPH : k === "tree" ? I.folder : I.lock} + {k === "viewer" ? t("File Viewer") : k === "tree" ? t("File Tree") : t("File Locks")} + { e.stopPropagation(); closeViewTab(k); }} title={t("Close")}>× +
+ ))} +
+ )} +
+ {viewTabs.length === 0 &&
{I.folder}{t("No panel open.\nAdd File Viewer or File Tree from the Window menu.")}
} + {viewTabs.includes("viewer") && ( +
+ {tab === "changes" ? ( + setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} /> + ) : ( +
+ + {histFile && (<> +
{ + e.preventDefault(); + const startX = e.clientX, start = histW; + document.body.classList.add("resizing"); + const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620))); + const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); }; + document.addEventListener("pointermove", move); document.addEventListener("pointerup", up); + // mouse fallback in case pointer events don't fire in the webview + document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up); + window.addEventListener("blur", up); // released outside the window → end drag + }} /> + setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} /> + )} +
+ )} +
+ )} + {viewTabs.includes("tree") && ( +
+ +
+ )} + {viewTabs.includes("locks") && ( +
+ lockFiles([dp], false)} /> +
+ )}
- )} +
{dockOpen && setDockOpen(false)} @@ -2062,48 +2193,56 @@ function SearchModal({ scope, onClose, onOpenEditor, onReveal, onCopy }: { scope } /* ---------------- file locks viewer — who has what locked/open across the depot ---------------- */ -function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; onClose: () => void; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) { +// the File Locks list (bar + rows), reused by the modal AND the dockable panel +function LocksPanel({ me, onReveal, onFlash, onUnlock }: { me: string; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) { const [files, setFiles] = useState([]); const [busy, setBusy] = useState(true); const [q, setQ] = useState(""); const [lockedOnly, setLockedOnly] = useState(true); const load = () => { setBusy(true); p4.openedAll("").then(setFiles).catch((e) => { onFlash(String(e), true); setFiles([]); }).finally(() => setBusy(false)); }; - useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line + useEffect(() => { load(); }, []); // eslint-disable-line const isLocked = (f: OpenedFile) => (f as Record).ourLock !== undefined; const s = q.trim().toLowerCase(); const rows = files.filter((f) => (!lockedOnly || isLocked(f)) && (!s || (f.depotFile || "").toLowerCase().includes(s) || (f.user || "").toLowerCase().includes(s))); - const lockedCount = files.filter(isLocked).length; + return ( + <> +
+
{I.search} setQ(e.target.value)} />
+ + +
+
+ {busy &&
{t("Loading…")}
} + {!busy && rows.length === 0 &&
{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}
} + {rows.map((f) => { + const dp = f.depotFile || ""; + const sp = splitPath(dp); + const locked = isLocked(f); + const mine = (f.user || "").toLowerCase() === me.toLowerCase(); + return ( +
+ {locked ? I.lock : I.unlock} + {sp.name}{sp.dir} + {f.user}{mine ? " · " + t("you") : ""}{f.client} + {locked && mine && } + +
+ ); + })} +
+ + ); +} + +function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; onClose: () => void; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) { + useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line return (
e.stopPropagation()}>
{I.lock}

{t("File Locks")}

- {t("{n} open · {m} locked", { n: files.length, m: lockedCount })} - -
-
-
{I.search} setQ(e.target.value)} />
- - -
-
- {busy &&
{t("Loading…")}
} - {!busy && rows.length === 0 &&
{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}
} - {rows.map((f) => { - const dp = f.depotFile || ""; - const sp = splitPath(dp); - const locked = isLocked(f); - const mine = (f.user || "").toLowerCase() === me.toLowerCase(); - return ( -
- {locked ? I.lock : I.unlock} - {sp.name}{sp.dir} - {f.user}{mine ? " · " + t("you") : ""}{f.client} - {locked && mine && } - -
- ); - })} +
+
); @@ -2277,13 +2416,14 @@ function ReconcileModal({ scope, onClose, onFlash, onDone }: { scope: string; on 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 }) { +// one row in the tree; folders lazily load their children on first expand, +// files trigger onOpen (open in the File Viewer / reveal). +function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen }: { node: FsEntry; depth: number; load: (p: string) => Promise; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void }) { const [open, setOpen] = useState(false); const [kids, setKids] = useState(null); const [busy, setBusy] = useState(false); - async function toggle() { - if (!node.isDir) return; + async function activate() { + if (!node.isDir) { onOpen?.(node); return; } if (!open && kids === null) { setBusy(true); try { setKids(sortBySize(await load(node.path))); } @@ -2296,7 +2436,7 @@ function ExpNode({ node, depth, load, max, onReveal, onFlash }: { node: FsEntry; 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} @@ -2305,13 +2445,16 @@ function ExpNode({ node, depth, load, max, onReveal, onFlash }: { node: FsEntry; {humanSize(node.size)} { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder}
- {open && kids && kids.map((k) => )} + {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"); +// The tree body (Depot / Workspace toggle + lazy size tree), reused by the +// Files & Sizes modal AND the embedded File Tree view-panel. +function FileTree({ scope, onReveal, onFlash, onOpenFile, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) { + const [side, setSide] = useState<"depot" | "workspace">(initialSide ?? "depot"); + useEffect(() => { if (initialSide) setSide(initialSide); }, [sideNonce]); // eslint-disable-line const [kids, setKids] = useState(null); const [busy, setBusy] = useState(true); const [err, setErr] = useState(""); @@ -2321,36 +2464,44 @@ function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; o 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 + load(rootPath).then((k) => { if (live) setKids(sortBySize(k)); }).catch((e) => { if (live) { setErr(String(e)); setKids([]); } }).finally(() => live && setBusy(false)); + return () => { live = false; }; + }, [side, scope]); // eslint-disable-line const total = (kids || []).reduce((a, b) => a + b.size, 0); const max = Math.max(1, ...(kids || []).map((k) => k.size)); + return ( + <> +
+
+ + +
+ {rootLabel} · {humanSize(total)} +
+
+ {busy &&
{t("Weighing…")}
} + {!busy && err &&
{err}
} + {!busy && kids && kids.length === 0 && !err &&
{t("Empty.")}
} + {!busy && kids && kids.map((k) => onOpenFile(n, side) : undefined} />)} +
+ + ); +} + +function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; onClose: () => void; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) { + useEffect(() => { + const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", k); + return () => document.removeEventListener("keydown", k); + }, []); // eslint-disable-line 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) => )} +
+
{t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.")}
+ onReveal(n.path)} />
); @@ -2859,20 +3010,23 @@ function ChangeDetail({ detail, busy, onOpen, selected, split, width }: { detail } /* ---------------- history list ---------------- */ -function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) { +function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { history: Change[]; sizes?: Record; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) { if (busy && !history.length) return
{t("Loading history…")}
; if (!history.length) return
{t("No submitted changes.")}
; return (
- {history.map((c) => ( -
onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}> - - - {(c.desc || "").trim() || t("(no description)")} - #{c.change} · {c.user} · {fmtTime(c.time)} - -
- ))} + {history.map((c) => { + const sz = sizes?.[c.change || ""]; + return ( +
onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}> + + + {(c.desc || "").trim() || t("(no description)")} + #{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? {humanSize(sz)} : null} + +
+ ); + })}
); } @@ -3028,8 +3182,10 @@ function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDif {kind === "model" ? : kind === "image" ? - : kind === "uasset" ? - : } + : kind === "audio" ? + : kind === "video" ? + : kind === "uasset" ? + : }
); } @@ -3069,6 +3225,55 @@ function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec? ); } +/* ---------------- audio / video preview with playback (like Anchorpoint) ---------------- */ +const MEDIA_MIME: Record = { + wav: "audio/wav", mp3: "audio/mpeg", ogg: "audio/ogg", oga: "audio/ogg", flac: "audio/flac", m4a: "audio/mp4", aac: "audio/aac", opus: "audio/opus", + mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4", ogv: "video/ogg", +}; +function MediaViewer({ depot, name, spec, video }: { depot: string; name: string; spec?: string; video: boolean }) { + const [url, setUrl] = useState(null); + const [err, setErr] = useState(""); + const [dur, setDur] = useState(""); + useEffect(() => { + let u = ""; let live = true; + setUrl(null); setErr(""); setDur(""); + (spec ? p4.printDepot(spec) : p4.readDepot(depot)).then((buf) => { + if (!live) return; + const ext = (name.split(".").pop() || "").toLowerCase(); + u = URL.createObjectURL(new Blob([buf], { type: MEDIA_MIME[ext] || (video ? "video/mp4" : "audio/mpeg") })); + setUrl(u); + }).catch((e) => live && setErr(String(e))); + return () => { live = false; if (u) URL.revokeObjectURL(u); }; + }, [depot, name, spec, video]); + const onMeta = (e: { currentTarget: HTMLMediaElement }) => { + const d = e.currentTarget.duration; + if (isFinite(d)) { const m = Math.floor(d / 60), s = Math.round(d % 60); setDur(`${m}:${s.toString().padStart(2, "0")}`); } + }; + const ext = (name.split(".").pop() || "").toUpperCase(); + return ( +
+
+ {video ? I.hex : SOUND_GLYPH}{video ? t("Video") : t("Audio")} + {err ?
{err}
+ : !url ?
{t("Loading…")}
+ : video + ?
+
+ +
+
+ ); +} +const SOUND_GLYPH = ; + /* ---------------- .uasset — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */ function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) { const dp = file.depotFile || ""; diff --git a/src/CodeView.tsx b/src/CodeView.tsx index 640fe75..0497607 100644 --- a/src/CodeView.tsx +++ b/src/CodeView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import hljs from "highlight.js/lib/core"; import cpp from "highlight.js/lib/languages/cpp"; import csharp from "highlight.js/lib/languages/csharp"; @@ -104,32 +104,53 @@ export default function CodeView({ file, onFlash }: { file: OpenedFile; onFlash? try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); } catch { return esc(code); } }, [code, lang]); + // live-highlighted draft for the editor underlay (same highlighter as the preview) + const draftHi = useMemo(() => { + try { return lang ? hljs.highlight(draft, { language: lang, ignoreIllegals: true }).value : esc(draft); } + catch { return esc(draft); } + }, [draft, lang]); + const preRef = useRef(null); + const gutRef = useRef(null); + const draftRows = draft.split("\n").length; - // editing mode — plain textarea over the working copy + // editing mode — a real code editor: transparent textarea over a syntax- + // highlighted underlay, with a line-number gutter (looks like the preview). if (editing) { + const syncScroll = (e: { currentTarget: HTMLTextAreaElement }) => { + const ta = e.currentTarget; + if (preRef.current) { preRef.current.scrollTop = ta.scrollTop; preRef.current.scrollLeft = ta.scrollLeft; } + if (gutRef.current) gutRef.current.scrollTop = ta.scrollTop; + }; return (
{t("Editing")} {name} + {lang ? {LANG_LABEL[lang]} : null}
-