diff --git a/.gitignore b/.gitignore index a547bf3..53b2051 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Secrets — never commit keys (OpenRouter key is baked at build time from src-tauri/openrouter.key) +*.key +latest.json diff --git a/RELEASING.md b/RELEASING.md index 8b23c7d..b1ad12e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -16,11 +16,19 @@ https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-viewer-perforc Без приватного ключа собрать валидное обновление невозможно — сделай бэкап ключа в надёжном месте (менеджер паролей / приватный репозиторий секретов). +## Ключ OpenRouter (AI-функции) + +AI-ключ **вшивается в бинарник при компиляции** из файла `src-tauri/openrouter.key` +(git-ignored, `*.key` в .gitignore). Без этого файла сборка упадёт с ошибкой +`include_str!` — перед сборкой убедись, что файл на месте. Чтобы сменить ключ, +замени содержимое файла и пересобери. + ## Как выпустить новую версию -1. Подними версию в **двух** местах (должны совпадать): +1. Подними версию в **трёх** местах (должны совпадать): - `package.json` → `"version"` - `src-tauri/tauri.conf.json` → `"version"` + - `src-tauri/Cargo.toml` → `version` 2. Собери подписанный инсталлятор: ```powershell diff --git a/index.html b/index.html index ff93803..c1b3713 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,8 @@ - - Tauri + React + Typescript + Exbyte Depot diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1533ea6..e713883 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -874,6 +874,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -953,6 +962,7 @@ dependencies = [ name = "exbyte-depot" version = "0.2.2" dependencies = [ + "encoding_rs", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c5c02cf..1e6d48e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "exbyte-depot" version = "0.2.2" -description = "A Tauri App" -authors = ["you"] +description = "Exbyte Depot — native Perforce client by Exbyte Studios" +authors = ["Exbyte Studios"] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -23,6 +23,8 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-dialog = "2.7.1" +# decode p4 output on non-UTF8 (ANSI/cp1251) systems — Cyrillic paths +encoding_rs = "0.8" # Auto-updater (desktop only — checks Gitea Releases for a signed new version) [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd8acbd..4f33cf8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,13 +33,58 @@ struct Conn { port: String, user: String, client: String, + root: String, // local client root (populated after login/restore/switch) } #[derive(Default)] struct AppState(Mutex); +/// Decode p4 output bytes: strict UTF-8 first, else the Windows ANSI codepage +/// (cp1251 on Russian systems) so Cyrillic file paths survive round-trips +/// instead of turning into U+FFFD and breaking later revert/submit calls. +fn decode(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + Err(_) => encoding_rs::WINDOWS_1251.decode(bytes).0.into_owned(), + } +} + +/// Preview/transfer size limit: reads above this are rejected instead of +/// buffering a multi-GB .pak/.uasset in memory and shipping it over IPC. +const MAX_READ_BYTES: u64 = 64 * 1024 * 1024; +fn check_local_size(path: &str) -> Result<(), String> { + if let Ok(m) = std::fs::metadata(path) { + if m.len() > MAX_READ_BYTES { + return Err(format!( + "File is too large to preview ({} MB, limit {} MB)", + m.len() / (1024 * 1024), + MAX_READ_BYTES / (1024 * 1024) + )); + } + } + Ok(()) +} + +/// Reject local paths outside the client root (workspace confinement for +/// filesystem-touching IPC commands). +fn ensure_under_root(conn: &Conn, path: &str) -> Result<(), String> { + if conn.root.is_empty() { + return Err("No workspace root known for this connection".into()); + } + let canon_root = std::fs::canonicalize(&conn.root) + .map_err(|e| format!("Workspace root not accessible: {e}"))?; + let canon = std::fs::canonicalize(path).map_err(|e| format!("File not found: {e}"))?; + if !canon.starts_with(&canon_root) { + return Err("Path is outside the workspace".into()); + } + Ok(()) +} + /// Build a `p4` command with the connection's global flags applied. /// On Windows we set CREATE_NO_WINDOW so no console flashes on every call. +/// stdin defaults to null so a command that unexpectedly prompts (expired +/// ticket, interactive subcommand) errors out instead of hanging forever; +/// commands that feed stdin (login, change -i) override it with piped(). fn p4_cmd(conn: &Conn, tagged: bool) -> Command { let mut c = Command::new("p4"); #[cfg(windows)] @@ -47,6 +92,7 @@ fn p4_cmd(conn: &Conn, tagged: bool) -> Command { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } + c.stdin(Stdio::null()); if !conn.port.is_empty() { c.args(["-p", &conn.port]); } @@ -73,7 +119,7 @@ fn run_json(conn: &Conn, args: &[&str]) -> Result, String> { } }; - let text = String::from_utf8_lossy(&out.stdout); + let text = decode(&out.stdout); let mut items = Vec::new(); for line in text.lines() { let t = line.trim(); @@ -106,7 +152,7 @@ fn run_json(conn: &Conn, args: &[&str]) -> Result, String> { items.push(v); } if items.is_empty() && !out.status.success() { - let e = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let e = decode(&out.stderr).trim().to_string(); if !e.is_empty() { log_p4(args, 0, false, Some(&e)); return Err(e); @@ -116,25 +162,45 @@ fn run_json(conn: &Conn, args: &[&str]) -> Result, String> { Ok(items) } -/// Trust the SSL fingerprint (first-time connect to an ssl: server). -fn trust(port: &str) { +/// Trust the SSL fingerprint on FIRST connect only (no `-f`): if the server's +/// fingerprint later CHANGES — the signature of a MITM/substituted server — +/// p4 refuses and we surface that instead of silently re-trusting and then +/// sending the user's password to the impostor. +fn trust(port: &str) -> Result<(), String> { let mut c = Command::new("p4"); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } - let _ = c.args(["-p", port, "trust", "-y", "-f"]).output(); + c.stdin(Stdio::null()); + let out = match c.args(["-p", port, "trust", "-y"]).output() { + Ok(o) => o, + Err(_) => return Ok(()), // p4 missing → the next real command reports it properly + }; + if !out.status.success() { + let e = decode(&out.stderr); + let el = e.to_lowercase(); + // only a fingerprint MISMATCH is fatal; "not an SSL connection" etc. is fine + if el.contains("has changed") || el.contains("mismatch") { + return Err(format!( + "SECURITY: the server's SSL fingerprint has CHANGED — possible man-in-the-middle. \ + Verify the server before connecting.\n{}", + e.trim() + )); + } + } + Ok(()) } /// List workspaces (clients) for a user — used to populate the connect screen. #[tauri::command] async fn p4_clients(port: String, user: String) -> Result, String> { - trust(&port); + trust(&port)?; let conn = Conn { port, user: user.clone(), - client: String::new(), + ..Default::default() }; run_json(&conn, &["clients", "-u", &user]) } @@ -149,11 +215,12 @@ async fn p4_login( password: String, client: String, ) -> Result { - trust(&port); - let conn = Conn { + trust(&port)?; + let mut conn = Conn { port: port.clone(), user: user.clone(), client: client.clone(), + ..Default::default() }; let mut child = p4_cmd(&conn, false) @@ -170,9 +237,9 @@ async fn p4_login( } let out = child.wait_with_output().map_err(|e| e.to_string())?; if !out.status.success() { - let err = String::from_utf8_lossy(&out.stderr); + let err = decode(&out.stderr); let err = if err.trim().is_empty() { - String::from_utf8_lossy(&out.stdout).trim().to_string() + decode(&out.stdout).trim().to_string() } else { err.trim().to_string() }; @@ -183,9 +250,11 @@ async fn p4_login( }); } - *state.0.lock().unwrap() = conn.clone(); let info = run_json(&conn, &["info"])?; - Ok(info.into_iter().next().unwrap_or(Value::Null)) + let info = info.into_iter().next().unwrap_or(Value::Null); + conn.root = info.get("clientRoot").and_then(|r| r.as_str()).unwrap_or("").to_string(); + *state.0.lock().unwrap_or_else(|e| e.into_inner()) = conn; + Ok(info) } /// Restore a saved session using the on-disk p4 ticket (no password). @@ -197,17 +266,19 @@ async fn p4_restore( user: String, client: String, ) -> Result { - trust(&port); - let conn = Conn { port, user, client }; + trust(&port)?; + let mut conn = Conn { port, user, client, ..Default::default() }; // `p4 login -s` reports ticket status; errors if not authenticated. run_json(&conn, &["login", "-s"])?; - *state.0.lock().unwrap() = conn.clone(); let info = run_json(&conn, &["info"])?; - Ok(info.into_iter().next().unwrap_or(Value::Null)) + let info = info.into_iter().next().unwrap_or(Value::Null); + conn.root = info.get("clientRoot").and_then(|r| r.as_str()).unwrap_or("").to_string(); + *state.0.lock().unwrap_or_else(|e| e.into_inner()) = conn; + Ok(info) } fn current(state: &State) -> Result { - let c = state.0.lock().unwrap().clone(); + let c = state.0.lock().unwrap_or_else(|e| e.into_inner()).clone(); if c.port.is_empty() { return Err("No active connection".into()); } @@ -284,8 +355,17 @@ async fn p4_changes(state: State<'_, AppState>) -> Result, String> { if with_files.contains(&num) { result.push(c); } else { - // empty pending changelist — delete it (ignore failures, e.g. shelved files) - let _ = run_text(&conn, &["change", "-d", &num]); + // no opened files — but NEVER delete a changelist that holds shelved + // files: it looks "empty" here yet carries real work + let shelved = run_json(&conn, &["describe", "-s", "-S", &num]) + .ok() + .and_then(|v| v.into_iter().next()) + .map_or(false, |d| d.get("depotFile0").is_some()); + if shelved { + result.push(c); + } else { + let _ = run_text(&conn, &["change", "-d", &num]); + } } } Ok(result) @@ -301,7 +381,7 @@ async fn p4_history(state: State<'_, AppState>, path: String) -> Result) -> Result<(), String> { - *state.0.lock().unwrap() = Conn::default(); + *state.0.lock().unwrap_or_else(|e| e.into_inner()) = Conn::default(); Ok(()) } @@ -315,8 +395,8 @@ fn run_text(conn: &Conn, args: &[&str]) -> Result { return Err(m); } }; - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let stdout = decode(&out.stdout).trim().to_string(); + let stderr = decode(&out.stderr).trim().to_string(); if !out.status.success() && !stderr.is_empty() { log_p4(args, 0, false, Some(&stderr)); return Err(stderr); @@ -441,10 +521,10 @@ fn create_changelist(conn: &Conn, desc: &str) -> Result { } let out = child.wait_with_output().map_err(|e| e.to_string())?; if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + return Err(decode(&out.stderr).trim().to_string()); } // stdout: "Change 23 created." - let s = String::from_utf8_lossy(&out.stdout); + let s = decode(&out.stdout); s.split_whitespace() .find(|w| w.chars().all(|c| c.is_ascii_digit()) && !w.is_empty()) .map(|n| n.to_string()) @@ -538,9 +618,9 @@ async fn p4_set_desc(state: State<'_, AppState>, change: String, description: St } let res = child.wait_with_output().map_err(|e| e.to_string())?; if !res.status.success() { - return Err(String::from_utf8_lossy(&res.stderr).trim().to_string()); + return Err(decode(&res.stderr).trim().to_string()); } - Ok(String::from_utf8_lossy(&res.stdout).trim().to_string()) + Ok(decode(&res.stdout).trim().to_string()) } /// Submit the selected files with a description. @@ -629,6 +709,9 @@ async fn p4_scan(state: State<'_, AppState>, scope: String) -> Result #[tauri::command] async fn p4_undo_change(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; + if change.is_empty() || !change.chars().all(|c| c.is_ascii_digit()) { + return Err(format!("Invalid changelist number: {change}")); + } let cl = create_changelist(&conn, &format!("Revert change {change}"))?; let spec = format!("//...@={change}"); // -c keeps the undone files isolated in our changelist (won't touch other open work) @@ -673,10 +756,134 @@ async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result< Ok(()) } -/// Open a depot file's local working copy in VS Code (`code `). -/// `code` is a .cmd on Windows, so it must be launched through the shell. +/// A code editor / IDE detected on this machine. +#[derive(serde::Serialize, Clone)] +struct Editor { + id: String, // stable key ("vscode", "cursor", "vs", …) + name: String, // display name + exe: String, // resolved absolute .exe (empty → system-default association) + args: Vec, // args inserted before the file path (e.g. ["/edit"] for VS) +} + +/// First path in `cands` that exists on disk. +fn first_existing(cands: &[String]) -> Option { + cands.iter().find(|p| std::path::Path::new(p).exists()).cloned() +} + +/// Bounded recursive search for a file named `target` (case-insensitive) under `root`. +fn find_file_named(root: &std::path::Path, target: &str, depth: usize) -> Option { + if depth == 0 || !root.is_dir() { + return None; + } + let entries = std::fs::read_dir(root).ok()?; + let mut dirs = Vec::new(); + for e in entries.flatten() { + let p = e.path(); + if p.is_file() { + if p.file_name().and_then(|n| n.to_str()).map_or(false, |n| n.eq_ignore_ascii_case(target)) { + return Some(p.to_string_lossy().to_string()); + } + } else if p.is_dir() { + dirs.push(p); + } + } + for d in dirs { + if let Some(f) = find_file_named(&d, target, depth - 1) { + return Some(f); + } + } + None +} + +/// Locate Visual Studio's devenv.exe via vswhere (latest install). +fn find_devenv() -> Option { + let pf86 = std::env::var("ProgramFiles(x86)").ok()?; + let vswhere = format!("{pf86}\\Microsoft Visual Studio\\Installer\\vswhere.exe"); + if !std::path::Path::new(&vswhere).exists() { + return None; + } + let mut c = Command::new(&vswhere); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000); + } + let out = c.args(["-latest", "-property", "productPath"]).output().ok()?; + let s = decode(&out.stdout).trim().to_string(); + if s.is_empty() || !std::path::Path::new(&s).exists() { None } else { Some(s) } +} + +fn add_editor(out: &mut Vec, id: &str, name: &str, exe: Option, args: &[&str]) { + if let Some(exe) = exe { + out.push(Editor { id: id.into(), name: name.into(), exe, args: args.iter().map(|s| s.to_string()).collect() }); + } +} + +/// Scan for installed code editors / IDEs (Windows). Only installed ones are +/// returned; "System default" (open with the associated app) is always last. +fn detect_editors() -> Vec { + let la = std::env::var("LOCALAPPDATA").unwrap_or_default(); + let pf = std::env::var("ProgramFiles").unwrap_or_default(); + let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_default(); + let mut out: Vec = Vec::new(); + + add_editor(&mut out, "vscode", "VS Code", first_existing(&[ + format!("{la}\\Programs\\Microsoft VS Code\\Code.exe"), + format!("{pf}\\Microsoft VS Code\\Code.exe"), + format!("{pf86}\\Microsoft VS Code\\Code.exe"), + ]), &[]); + add_editor(&mut out, "cursor", "Cursor", first_existing(&[ + format!("{la}\\Programs\\cursor\\Cursor.exe"), + ]), &[]); + add_editor(&mut out, "windsurf", "Windsurf", first_existing(&[ + format!("{la}\\Programs\\Windsurf\\Windsurf.exe"), + ]), &[]); + add_editor(&mut out, "vscode-insiders", "VS Code Insiders", first_existing(&[ + format!("{la}\\Programs\\Microsoft VS Code Insiders\\Code - Insiders.exe"), + format!("{pf}\\Microsoft VS Code Insiders\\Code - Insiders.exe"), + ]), &[]); + add_editor(&mut out, "vs", "Visual Studio", find_devenv(), &["/edit"]); + + let jb_pf = format!("{pf}\\JetBrains"); + let jb_tb = format!("{la}\\JetBrains\\Toolbox\\apps"); + add_editor(&mut out, "rider", "JetBrains Rider", + find_file_named(std::path::Path::new(&jb_pf), "rider64.exe", 3) + .or_else(|| find_file_named(std::path::Path::new(&jb_tb), "rider64.exe", 6)), &[]); + add_editor(&mut out, "clion", "JetBrains CLion", + find_file_named(std::path::Path::new(&jb_pf), "clion64.exe", 3) + .or_else(|| find_file_named(std::path::Path::new(&jb_tb), "clion64.exe", 6)), &[]); + add_editor(&mut out, "idea", "IntelliJ IDEA", + find_file_named(std::path::Path::new(&jb_pf), "idea64.exe", 3) + .or_else(|| find_file_named(std::path::Path::new(&jb_tb), "idea64.exe", 6)), &[]); + + add_editor(&mut out, "sublime", "Sublime Text", first_existing(&[ + format!("{pf}\\Sublime Text\\sublime_text.exe"), + format!("{pf}\\Sublime Text 3\\sublime_text.exe"), + format!("{pf86}\\Sublime Text 3\\sublime_text.exe"), + ]), &[]); + add_editor(&mut out, "npp", "Notepad++", first_existing(&[ + format!("{pf}\\Notepad++\\notepad++.exe"), + format!("{pf86}\\Notepad++\\notepad++.exe"), + ]), &[]); + add_editor(&mut out, "zed", "Zed", first_existing(&[ + format!("{la}\\Programs\\Zed\\Zed.exe"), + format!("{la}\\Zed\\Zed.exe"), + ]), &[]); + + // always available: open with the OS-associated application + out.push(Editor { id: "default".into(), name: "System default".into(), exe: String::new(), args: vec![] }); + out +} + +/// List installed code editors for the Settings picker. #[tauri::command] -async fn open_in_vscode(state: State<'_, AppState>, depot: String) -> Result<(), String> { +async fn list_editors() -> Result, String> { + Ok(detect_editors()) +} + +/// Open a depot file's local working copy in the chosen editor (by id). +#[tauri::command] +async fn open_in_editor(state: State<'_, AppState>, depot: String, editor: String) -> Result<(), String> { let conn = current(&state)?; let local = run_json(&conn, &["where", &depot])? .into_iter() @@ -686,23 +893,50 @@ async fn open_in_vscode(state: State<'_, AppState>, depot: String) -> Result<(), if !std::path::Path::new(&local).exists() { return Err(format!("File is not on disk yet (not synced): {local}")); } - let mut c = Command::new("cmd"); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - c.creation_flags(0x0800_0000); + let editors = detect_editors(); + let ed = editors + .iter() + .find(|e| e.id == editor) + .or_else(|| editors.iter().find(|e| e.id == "default")) + .cloned() + .ok_or_else(|| "No editor available".to_string())?; + + if ed.exe.is_empty() { + // system-default association via `start` + let mut c = Command::new("cmd"); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000); + } + c.args(["/C", "start", "", &local]) + .spawn() + .map_err(|e| format!("Could not launch: {e}"))?; + } else { + let mut c = Command::new(&ed.exe); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000); + } + for a in &ed.args { + c.arg(a); + } + // pass the path as a separate arg so Rust quotes only it + c.arg(&local) + .spawn() + .map_err(|e| format!("Could not open in {}: {e}", ed.name))?; } - // pass args separately so Rust quotes only the path (no double-escaping through cmd) - c.args(["/C", "code", &local]); - c.spawn() - .map_err(|e| format!("VS Code not found (is `code` in PATH?): {e}"))?; Ok(()) } /// Open a local file with its associated application (e.g. launch Unreal for a -/// `.uproject`). Uses the shell `start` so there are no path-scope restrictions. +/// `.uproject`). Confined to the workspace root — the webview cannot use this +/// to execute arbitrary files elsewhere on disk. #[tauri::command] -async fn launch_file(path: String) -> Result<(), String> { +async fn launch_file(state: State<'_, AppState>, path: String) -> Result<(), String> { + let conn = current(&state)?; + ensure_under_root(&conn, &path)?; if !std::path::Path::new(&path).exists() { return Err(format!("File not found: {path}")); } @@ -803,7 +1037,7 @@ async fn build_sln(path: String, config: String) -> Result { } vc.args(["-latest", "-requires", "Microsoft.Component.MSBuild", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"]); match vc.output() { - Ok(o) => String::from_utf8_lossy(&o.stdout).lines().next().unwrap_or("").trim().to_string(), + Ok(o) => decode(&o.stdout).lines().next().unwrap_or("").trim().to_string(), Err(_) => String::new(), } }; @@ -900,8 +1134,12 @@ async fn p4_describe(state: State<'_, AppState>, change: String) -> Result Result { +async fn read_file(state: State<'_, AppState>, path: String) -> Result { + let conn = current(&state)?; + ensure_under_root(&conn, &path)?; + check_local_size(&path)?; std::fs::read(&path) .map(tauri::ipc::Response::new) .map_err(|e| format!("Could not read file: {e}")) @@ -916,6 +1154,7 @@ async fn read_depot(state: State<'_, AppState>, depot: String) -> Result, depot: String) -> Result, spec: String) -> Result { let conn = current(&state)?; + // size guard: reject huge revisions before buffering them via `p4 print` + if let Ok(sizes) = run_json(&conn, &["sizes", &spec]) { + if let Some(sz) = sizes.first().and_then(|v| v.get("fileSize")).and_then(|s| s.as_str()).and_then(|s| s.parse::().ok()) { + if sz > MAX_READ_BYTES { + return Err(format!( + "File is too large to preview ({} MB, limit {} MB)", + sz / (1024 * 1024), + MAX_READ_BYTES / (1024 * 1024) + )); + } + } + } let out = p4_cmd(&conn, false) .args(["print", "-q", &spec]) .output() .map_err(|e| format!("Failed to start p4: {e}"))?; if !out.status.success() && out.stdout.is_empty() { - let e = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let e = decode(&out.stderr).trim().to_string(); log_p4(&["print", "-q", &spec], 0, false, Some(&e)); return Err(if e.is_empty() { "p4 print failed".into() } else { e }); } + if out.stdout.len() as u64 > MAX_READ_BYTES { + return Err("File is too large to preview".into()); + } log_p4(&["print", "-q", &spec], out.stdout.len() as i64, true, None); Ok(tauri::ipc::Response::new(out.stdout)) } +/// Run an arbitrary `p4` command for the built-in terminal and return combined +/// stdout+stderr as text. Never errors on a non-zero exit — the terminal shows +/// the error text just like a real console would. +#[tauri::command] +async fn p4_console(state: State<'_, AppState>, args: Vec) -> Result { + let conn = current(&state)?; + let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + // stdin is null (p4_cmd default) so prompting commands fail fast instead of + // hanging; a failing P4EDITOR aborts editor-spawning forms (change, submit + // without -d, client, …) the same way. + let out = p4_cmd(&conn, false) + .env("P4EDITOR", "cmd /c exit 1") + .args(&refs) + .output() + .map_err(|e| format!("Failed to start p4: {e}"))?; + let mut s = decode(&out.stdout); + let err = decode(&out.stderr); + if !err.trim().is_empty() { + if !s.is_empty() && !s.ends_with('\n') { s.push('\n'); } + s.push_str(&err); + } + let ok = out.status.success(); + log_p4(&refs, s.lines().count() as i64, ok, if ok { None } else { Some(err.trim()) }); + Ok(s) +} + +/// Tail the newest Unreal log for a project. `uproject` is the local path of the +/// `.uproject`; the log lives at `/Saved/Logs/*.log`. Returns the +/// last `tail_bytes` bytes (empty string if no log yet — e.g. UE not running). +#[tauri::command] +async fn ue_log(state: State<'_, AppState>, uproject: String, tail_bytes: u64) -> Result { + use std::path::Path; + let conn = current(&state)?; + ensure_under_root(&conn, &uproject)?; + let dir = Path::new(&uproject) + .parent() + .ok_or("bad uproject path")? + .join("Saved") + .join("Logs"); + if !dir.is_dir() { + return Ok(String::new()); + } + let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None; + for e in std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten() { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) == Some("log") { + if let Ok(m) = e.metadata().and_then(|m| m.modified()) { + if newest.as_ref().map_or(true, |(t, _)| m > *t) { + newest = Some((m, p)); + } + } + } + } + let Some((_, path)) = newest else { return Ok(String::new()) }; + let data = std::fs::read(&path).map_err(|e| e.to_string())?; + let start = data.len().saturating_sub(tail_bytes as usize); + Ok(decode(&data[start..])) +} + +/// Files opened by ANYONE in the scope (`p4 opened -a`) — powers the +/// "who is holding this file" indicators and exclusive-checkout awareness. +#[tauri::command] +async fn p4_opened_all(state: State<'_, AppState>, scope: String) -> Result, String> { + let conn = current(&state)?; + if scope.is_empty() { + run_json(&conn, &["opened", "-a"]) + } else { + run_json(&conn, &["opened", "-a", &scope_spec(&scope)]) + } +} + +/// Lock opened files so nobody else can submit them (binary-asset safety). +#[tauri::command] +async fn p4_lock(state: State<'_, AppState>, files: Vec) -> Result { + let conn = current(&state)?; + let mut args: Vec<&str> = vec!["lock"]; + for f in &files { + args.push(f.as_str()); + } + run_text(&conn, &args) +} + +/// Release locks. +#[tauri::command] +async fn p4_unlock(state: State<'_, AppState>, files: Vec) -> Result { + let conn = current(&state)?; + let mut args: Vec<&str> = vec!["unlock"]; + for f in &files { + args.push(f.as_str()); + } + run_text(&conn, &args) +} + +/// Shelve a pending changelist's files on the server (share work-in-progress). +#[tauri::command] +async fn p4_shelve(state: State<'_, AppState>, change: String) -> Result { + let conn = current(&state)?; + run_text(&conn, &["shelve", "-f", "-c", &change]) +} + +/// Unshelve files from a shelved changelist into the workspace. +#[tauri::command] +async fn p4_unshelve(state: State<'_, AppState>, change: String) -> Result { + let conn = current(&state)?; + run_text(&conn, &["unshelve", "-s", &change, "-f"]) +} + +/// Delete the shelved files of a changelist. +#[tauri::command] +async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result { + let conn = current(&state)?; + run_text(&conn, &["shelve", "-d", "-c", &change]) +} + +/// Shelved files of a changelist (describe -S). +#[tauri::command] +async fn p4_shelved_files(state: State<'_, AppState>, change: String) -> Result { + let conn = current(&state)?; + let v = run_json(&conn, &["describe", "-s", "-S", &change])?; + Ok(v.into_iter().next().unwrap_or(Value::Null)) +} + +/// Files that need conflict resolution after a sync (`p4 resolve -n`). +#[tauri::command] +async fn p4_resolve_list(state: State<'_, AppState>) -> Result, String> { + let conn = current(&state)?; + // -n = preview only; never prompts + run_json(&conn, &["resolve", "-n"]).or_else(|e| { + // "no file(s) to resolve" is not an error — just an empty list + if e.to_lowercase().contains("no file") { Ok(Vec::new()) } else { Err(e) } + }) +} + +/// Resolve one file (or everything when `file` is empty). +/// mode: "am" auto-merge · "ay" accept yours · "at" accept theirs. +#[tauri::command] +async fn p4_resolve_file(state: State<'_, AppState>, file: String, mode: String) -> Result { + let conn = current(&state)?; + let flag = match mode.as_str() { + "ay" => "-ay", + "at" => "-at", + _ => "-am", + }; + if file.is_empty() { + run_text(&conn, &["resolve", flag]) + } else { + run_text(&conn, &["resolve", flag, &file]) + } +} + +/// Unified diff between two exact revisions (`p4 diff2 -du`). +#[tauri::command] +async fn p4_diff2(state: State<'_, AppState>, spec1: String, spec2: String) -> Result { + let conn = current(&state)?; + run_text(&conn, &["diff2", "-du", &spec1, &spec2]) +} + +/// Per-line blame: `p4 annotate -c -u -q` (changelist + user per line). +#[tauri::command] +async fn p4_annotate(state: State<'_, AppState>, spec: String) -> Result { + let conn = current(&state)?; + let mut s = run_text(&conn, &["annotate", "-c", "-u", "-q", &spec])?; + if s.len() > 2_000_000 { + s.truncate(2_000_000); + s.push_str("\n… (truncated)"); + } + Ok(s) +} + +/// Depot file search by name fragment (case-preserving p4 wildcards). +#[tauri::command] +async fn p4_search_files(state: State<'_, AppState>, query: String, scope: String) -> Result, String> { + let conn = current(&state)?; + let q = query.trim(); + if q.is_empty() { + return Ok(Vec::new()); + } + let base = if scope.is_empty() { + "//".to_string() + } else { + format!("{}/", scope.trim_end_matches(['/', '\\'])) + }; + // `...` spans directories; `*` stays within a segment → "name contains q" + let spec = format!("{base}...{q}*"); + run_json(&conn, &["files", "-e", "-m", "200", &spec]).or_else(|e| { + if e.to_lowercase().contains("no such file") { Ok(Vec::new()) } else { Err(e) } + }) +} + +/// Move opened files into another pending changelist ("default" allowed). +#[tauri::command] +async fn p4_reopen_to(state: State<'_, AppState>, change: String, files: Vec) -> Result { + let conn = current(&state)?; + if files.is_empty() { + return Ok(String::new()); + } + let mut args: Vec<&str> = vec!["reopen", "-c", &change]; + for f in &files { + args.push(f.as_str()); + } + run_text(&conn, &args) +} + +/// Newest submitted changelist in the scope — cheap poll for "new submit" toasts. +#[tauri::command] +async fn p4_latest_change(state: State<'_, AppState>, 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])?; + Ok(v.into_iter().next().unwrap_or(Value::Null)) +} + /// Switch the active workspace (client) without re-authenticating. #[tauri::command] async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result { let conn = { - let mut c = state.0.lock().unwrap(); + let mut c = state.0.lock().unwrap_or_else(|e| e.into_inner()); if c.port.is_empty() { return Err("No active connection".into()); } @@ -953,7 +1419,11 @@ async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result< c.clone() }; let info = run_json(&conn, &["info"])?; - Ok(info.into_iter().next().unwrap_or(Value::Null)) + let info = info.into_iter().next().unwrap_or(Value::Null); + // refresh the cached client root for the new workspace + let root = info.get("clientRoot").and_then(|r| r.as_str()).unwrap_or("").to_string(); + state.0.lock().unwrap_or_else(|e| e.into_inner()).root = root; + Ok(info) } /// List depots on the server (for the Depot-path picker). @@ -1043,12 +1513,17 @@ async fn p4_group_member( } let res = child.wait_with_output().map_err(|e| e.to_string())?; if !res.status.success() { - return Err(String::from_utf8_lossy(&res.stderr).trim().to_string()); + return Err(decode(&res.stderr).trim().to_string()); } - Ok(String::from_utf8_lossy(&res.stdout).trim().to_string()) + Ok(decode(&res.stdout).trim().to_string()) } +/// OpenRouter API key baked into the binary at compile time from the +/// git-ignored file src-tauri/openrouter.key. +const BAKED_AI_KEY: &str = include_str!("../openrouter.key"); + /// Path to the (git-ignored) OpenRouter API key, stored in the app config dir. +/// Kept as a fallback/override location for builds without a baked key. fn ai_key_path() -> Option { let app = APP.get()?; let dir = app.path().app_config_dir().ok()?; @@ -1056,6 +1531,10 @@ fn ai_key_path() -> Option { } #[tauri::command] async fn read_ai_key() -> Result { + let baked = BAKED_AI_KEY.trim(); + if !baked.is_empty() { + return Ok(baked.to_string()); + } Ok(ai_key_path() .and_then(|p| std::fs::read_to_string(p).ok()) .map(|s| s.trim().to_string()) @@ -1116,11 +1595,28 @@ pub fn run() { find_sln, build_sln, launch_file, - open_in_vscode, + list_editors, + open_in_editor, p4_describe, read_file, read_depot, print_depot, + p4_console, + ue_log, + p4_opened_all, + p4_lock, + p4_unlock, + p4_shelve, + p4_unshelve, + p4_delete_shelf, + p4_shelved_files, + p4_resolve_list, + p4_resolve_file, + p4_diff2, + p4_annotate, + p4_search_files, + p4_reopen_to, + p4_latest_change, p4_switch_client, p4_depots, p4_users, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7aebfc0..7de62b4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,8 @@ } ], "security": { - "csp": null + "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'" } }, "bundle": { diff --git a/src/App.css b/src/App.css index 1e40bfa..0adcb7d 100644 --- a/src/App.css +++ b/src/App.css @@ -363,6 +363,38 @@ body.resizing{cursor:col-resize!important;user-select:none} .logcount{margin-left:auto;flex:0 0 auto;color:var(--faint);white-space:nowrap} .logrow.err .logcount{color:var(--del)} +/* bottom dock — tabbed Log / Terminal / Unreal */ +.dockpanel{position:relative;flex:0 0 240px;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--border);background:var(--sunk)} +.dockhandle{position:absolute;top:-3px;left:0;right:0;height:7px;cursor:row-resize;z-index:9} +.dockhandle:hover{background:var(--accent);opacity:.5} +body.resizing-v{cursor:row-resize!important;user-select:none} +.dockhead{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:4px 10px 0;border-bottom:1px solid var(--border-soft)} +.docktabs{display:flex;gap:2px} +.docktab{display:flex;align-items:center;gap:6px;border:none;background:none;color:var(--muted);font-family:var(--font);font-size:12px;cursor:pointer;padding:7px 12px;border-radius:8px 8px 0 0;border-bottom:2px solid transparent;transition:.12s} +.docktab svg{width:14px;height:14px} +.docktab:hover{color:var(--txt);background:var(--hover)} +.docktab.on{color:var(--txt);border-bottom-color:var(--accent)} +.docktab.on svg{color:var(--accent-2)} +.dockspace{flex:1} +.dockpanel .loghbtn{margin-left:0} +/* terminal */ +.term{flex:1;display:flex;flex-direction:column;min-height:0} +.termbody{flex:1;overflow-y:auto;min-height:0;padding:8px 12px;font-family:var(--mono);font-size:12px;line-height:1.5} +.termhint{color:var(--faint);font-family:var(--font);font-size:12px;padding:8px 2px;line-height:1.55} +.termhint.err{color:var(--del)} +.termblock{margin-bottom:9px} +.termcmd{color:var(--txt);white-space:pre-wrap;word-break:break-all} +.termprompt{color:var(--accent);font-weight:700;user-select:none;margin-right:3px} +.termout{margin:2px 0 0;white-space:pre-wrap;word-break:break-word;color:var(--muted)} +.termout.err{color:var(--del)} +.termout.run{color:var(--faint);display:flex;align-items:center;gap:8px} +.terminput-wrap{position:relative;flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:9px 12px;border-top:1px solid var(--border-soft);background:var(--panel)} +.terminput{flex:1;background:none;border:none;outline:none;color:var(--txt);font-family:var(--mono);font-size:12.5px} +.termsug{position:absolute;left:32px;bottom:calc(100% - 1px);min-width:210px;max-height:220px;overflow-y:auto;background:var(--elevated);border:1px solid var(--border);border-radius:10px;box-shadow:0 -14px 34px -14px rgba(0,0,0,.65);z-index:20;padding:4px} +.termsug-i{padding:6px 10px;font-family:var(--mono);font-size:12px;color:var(--muted);border-radius:7px;cursor:pointer;white-space:nowrap} +.termsug-i b{color:var(--accent-2)} +.termsug-i.on,.termsug-i:hover{background:rgba(124,110,246,.14);color:var(--txt)} + /* animated startup splash */ .splash{display:flex;flex-direction:column;align-items:center;gap:16px;padding:20px;animation:updin .5s ease} .splash-logo{width:64px;height:64px;border-radius:18px;display:grid;place-items:center;color:#fff; @@ -465,6 +497,14 @@ body.resizing{cursor:col-resize!important;user-select:none} .gbtn:hover{color:var(--txt);border-color:var(--accent)}.gbtn svg{width:14px;height:14px} .gbtn.vsc svg{color:#3aa0ff} .gbtn.vsc:hover{border-color:#3aa0ff;color:#3aa0ff} +.gbtn.icon{padding:7px;width:34px;height:32px;justify-content:center;gap:0} +.gbtn.icon svg{width:16px;height:16px} +/* code-editor picker in Settings */ +.edsel{flex:none;min-width:170px;font-size:12.5px;font-family:var(--font);color:var(--txt); + background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:7px 10px;cursor:pointer;transition:.15s} +.edsel:hover{border-color:var(--accent)} +.edsel:focus{outline:none;border-color:var(--accent)} +.edsel option{background:var(--panel);color:var(--txt)} .asset{flex:1;min-height:0;display:grid;grid-template-rows:1fr auto} .stage{position:relative;min-height:0;overflow:hidden;display:grid;place-items:center; @@ -567,9 +607,9 @@ body.resizing{cursor:col-resize!important;user-select:none} /* History = GitHub-style split: changed-files list beside the file content (no covering) */ .histsplit{display:flex;min-width:0;min-height:0;overflow:hidden} .histsplit>.right{flex:1 1 auto;min-width:0} -/* explicit width via a state-driven CSS variable */ -.histsplit>.histlist{flex:none;width:var(--hist-w,340px);min-width:0;border-right:1px solid var(--border)} -.histsplit.solo>.histlist{flex:1 1 auto;width:auto;border-right:none} /* nothing open → list fills the panel */ +/* width comes from a direct inline style on the element (set in ChangeDetail) */ +.histsplit>.histlist{min-width:0;border-right:1px solid var(--border)} +.histsplit.solo>.histlist{flex:1 1 auto;border-right:none} /* nothing open → list fills the panel */ /* plain in-flow 8px column between list and preview — only the cursor hints it's draggable. position MUST override the base .vhandle{position:absolute}, or it falls out of the flex flow */ .histsplit>.histh{position:relative;flex:none;width:8px;align-self:stretch;cursor:col-resize;background:transparent;top:auto;bottom:auto} @@ -673,6 +713,60 @@ body.resizing{cursor:col-resize!important;user-select:none} .aibtn svg{width:15px;height:15px} .aibtn.busy{color:var(--faint)} .aibtn.icon{padding:0;width:34px;justify-content:center} +/* code "Summary" button + AI explanation card in the preview header */ +.gbtn.ai{color:var(--accent-2);border-color:rgba(124,110,246,.35);background:linear-gradient(135deg,rgba(124,110,246,.14),rgba(124,110,246,.05))} +.gbtn.ai:hover{filter:brightness(1.1);border-color:rgba(124,110,246,.55)} +.gbtn.ai.busy{color:var(--faint)} +.explain{flex:0 0 auto;display:flex;align-items:flex-start;gap:10px;margin:0;padding:12px 16px; + background:linear-gradient(135deg,rgba(124,110,246,.1),rgba(124,110,246,.03));border-bottom:1px solid var(--border-soft)} +.explain-ic{flex:0 0 auto;color:var(--accent-2);display:flex} +.explain-ic svg{width:17px;height:17px} +.explain-body{flex:1;font-size:12.5px;line-height:1.55;color:var(--txt);min-width:0} +.explain-load{display:flex;align-items:center;gap:8px;color:var(--faint)} +.explain-x{flex:0 0 auto;border:none;background:none;color:var(--faint);font-size:18px;line-height:1;cursor:pointer;padding:0 2px} +.explain-x:hover{color:var(--txt)} + +/* ---------------- collaboration UI (locks / banners / search / blame / diff / resolve) ---------------- */ +/* connection + resolve banners */ +.netbar{display:flex;align-items:center;gap:10px;padding:8px 16px;font-size:12.5px;font-weight:600;flex:0 0 auto} +.netbar svg{width:16px;height:16px;flex:0 0 auto} +.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:hover{background:rgba(232,176,75,.2)} +.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)} +/* "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); + background:var(--chip);border:1px solid var(--border-soft);border-radius:7px;padding:2px 7px;max-width:120px;overflow:hidden} +.held svg{width:12px;height:12px;flex:0 0 auto} +.held span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.held.locked{color:var(--del);border-color:rgba(242,99,126,.35);background:rgba(242,99,126,.1)} +/* search results / resolve list share the srch layout */ +.srch-list{flex:1;overflow-y:auto;padding:6px 10px 12px} +.srch-row{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:9px;transition:background .12s} +.srch-row:hover{background:var(--hover)} +.srch-body{flex:1;display:flex;flex-direction:column;gap:1px;min-width:0} +.srch-body .n{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.srch-body .p{font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.srch-act{flex:0 0 auto;width:30px;height:28px;display:grid;place-items:center;border:1px solid var(--border); + background:var(--panel);border-radius:8px;cursor:pointer;color:var(--muted);font-size:13px} +.srch-act:hover{color:var(--txt);border-color:var(--accent)}.srch-act svg{width:15px;height:15px} +.rslv-bar{display:flex;gap:8px;padding:10px 16px;border-bottom:1px solid var(--border-soft);flex-wrap:wrap} +.rslv-act{flex:0 0 auto;border:1px solid var(--border);background:var(--panel);border-radius:7px;padding:5px 11px; + font-size:11.5px;font-weight:600;color:var(--muted);cursor:pointer;font-family:var(--font)} +.rslv-act:hover{color:var(--txt);border-color:var(--accent)} +/* blame + diff panes */ +.picker.blame{width:900px;height:640px} +.blame-body,.diff-body{flex:1;overflow:auto;padding:8px 0;font-family:var(--mono);font-size:12px;line-height:1.5;background:var(--stage-bg)} +.blame-row{display:flex;gap:0;padding:0 12px;white-space:pre} +.blame-row:hover{background:var(--hover)} +.bl-cl{flex:0 0 64px;color:var(--accent-2);font-variant-numeric:tabular-nums} +.bl-user{flex:0 0 110px;color:var(--edit);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bl-code{flex:1;color:var(--txt);white-space:pre} +.diff-line{padding:0 14px;white-space:pre;color:var(--muted)} +.diff-line.add{background:rgba(62,207,142,.12);color:var(--diff-add-c)} +.diff-line.del{background:rgba(242,99,126,.12);color:var(--diff-del-c)} +.diff-line.hunk{background:rgba(124,110,246,.1);color:var(--accent-2)} /* AI settings block inside Settings modal */ .ai-set{margin-top:16px;padding-top:14px;border-top:1px solid var(--border-soft)} diff --git a/src/App.tsx b/src/App.tsx index 23856ee..f6372b4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { MouseEvent as ReactMouseEvent, CSSProperties, ReactNode } from "react"; +import type { MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, CSSProperties, ReactNode } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { listen } from "@tauri-apps/api/event"; @@ -7,9 +7,9 @@ import ModelViewer from "./ModelViewer"; import CodeView from "./CodeView"; import UpdateBanner from "./Updater"; import "./App.css"; -import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User } from "./p4"; +import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor } from "./p4"; import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n"; -import { aiSummary, initials, avatarColor, activity, getModel, setModel, getKeyLocal, setKeyLocal } from "./ai"; +import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai"; /* ---------------- window controls (frameless) ---------------- */ function WinControls() { @@ -42,10 +42,15 @@ const I = { chevron: , spark: , people: , + terminal: , clock: , log: , vscode: , hammer: , + lock: , + unlock: , + shelf: , + folder: , ue: , }; @@ -272,10 +277,14 @@ type ModalState = | { kind: "scope"; path: string; dirs: Dir[] } | { kind: "settings" } | { kind: "users" } + | { kind: "search" } + | { kind: "blame"; spec: string; name: string } + | { kind: "diff"; title: string; text: string } | { kind: "about" }; type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null }; type Transfer = { op: "sync" | "submit"; count: number; file?: string }; +type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean }; function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }: { info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) { @@ -311,6 +320,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building) + const [editors, setEditors] = useState([]); // code editors installed on this machine + const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick) + const [others, setOthers] = useState>(new Map()); // files opened/locked by OTHER users + const [needResolve, setNeedResolve] = useState([]); // files awaiting conflict resolution + const [resolveOpen, setResolveOpen] = useState(false); + const [offline, setOffline] = useState(false); // lost connection to the server + 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 const [building, setBuilding] = useState(false); const [buildOk, setBuildOk] = useState(null); @@ -320,7 +337,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [aiBusy, setAiBusy] = useState(false); // AI commit-summary generation in flight const [ctx, setCtx] = useState(null); // right-click menu const [logs, setLogs] = useState([]); // live p4 command log (P4V-style) - const [logOpen, setLogOpen] = useState(false); + // 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"); + 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 + const [termInput, setTermInput] = useState(""); + const [cmdHist, setCmdHist] = useState([]); // recalled with ↑/↓ + const [termBusy, setTermBusy] = useState(false); + const termId = useRef(0); const [peek, setPeek] = useState(false); // hover-over-loader log preview const logId = useRef(0); const peekTimer = useRef(null); @@ -342,8 +368,27 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set else if (kind === "hist") setHistW(Math.max(240, Math.min(startHist + (ev.clientX - startX), 620))); else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520))); }; - const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.classList.remove("resizing"); }; - document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); document.body.classList.add("resizing"); + const up = () => { + document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); + window.removeEventListener("blur", up); document.body.classList.remove("resizing"); + }; + document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); + window.addEventListener("blur", up); // mouse released outside the window → end the drag + document.body.classList.add("resizing"); + } + + // drag the top edge of the bottom dock to make it taller/shorter + function startDockResize(e: ReactMouseEvent) { + e.preventDefault(); + const startY = e.clientY, startH = dockH; + const move = (ev: MouseEvent) => setDockH(Math.max(120, Math.min(startH - (ev.clientY - startY), window.innerHeight - 220))); + const up = () => { + document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); + window.removeEventListener("blur", up); document.body.classList.remove("resizing-v"); + }; + document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); + window.addEventListener("blur", up); + document.body.classList.add("resizing-v"); } function flash(text: string, err = false) { @@ -354,16 +399,112 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set return new Promise((resolve) => setAsk({ ...opts, resolve })); } + // open the bottom dock on a given tab (toggle off if already showing it) + function openDock(tab: "log" | "terminal" | "unreal") { + setDockOpen((o) => (o && dockTab === tab ? false : true)); + setDockTab(tab); + } + // run a p4 command typed into the terminal (combined stdout+stderr streamed back) + async function runConsole(raw: string) { + const line = raw.trim(); + if (!line) return; + setCmdHist((h) => [...h.filter((x) => x !== line), line].slice(-100)); + setTermInput(""); + // split into args honouring quotes; drop a leading "p4" if the user typed it + const args = (line.match(/(?:[^\s"]+|"[^"]*")+/g) || []).map((a) => a.replace(/^"|"$/g, "")); + if (args[0]?.toLowerCase() === "p4") args.shift(); + if (!args.length) return; + const id = ++termId.current; + setTermLines((l) => [...l.slice(-200), { id, cmd: line, text: "", running: true }]); + setTermBusy(true); + try { + const out = await p4.console(args); + setTermLines((l) => l.map((x) => (x.id === id ? { ...x, text: out.trimEnd() || t("(no output)"), running: false } : x))); + } catch (e) { + setTermLines((l) => l.map((x) => (x.id === id ? { ...x, text: String(e), err: true, running: false } : x))); + } finally { setTermBusy(false); } + } + + // global keyboard shortcuts. Matches on e.code (layout-independent — works on a + // Russian keyboard where Backquote types "ё"); F5/Ctrl+R are intercepted so the + // WebView doesn't reload the whole app. Typing shortcuts are ignored while a + // text field is focused; the dock toggles (Ctrl+L/`) still work everywhere. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + const typing = !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable); + const mod = e.ctrlKey || e.metaKey; + // dock toggles — allowed even while typing + if (mod && e.code === "KeyL") { e.preventDefault(); openDock("log"); return; } + if (mod && e.code === "Backquote") { e.preventDefault(); openDock("terminal"); return; } + // refresh — F5 or Ctrl+R (block the webview's built-in reload) + if (e.code === "F5" || (mod && e.code === "KeyR")) { e.preventDefault(); if (!typing) refresh(); return; } + if (typing) return; + if (mod && e.code === "KeyG") { e.preventDefault(); getLatest(); } + else if (mod && e.code === "KeyS") { e.preventDefault(); pushAll(); } + else if (mod && e.code === "KeyB") { e.preventDefault(); startBuild(); } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + // eslint-disable-next-line + }, [dockOpen, dockTab, activePath, pending, slnPath]); + // load the currently-open files + pending changelists (fast — just reads p4 state) - async function loadOpened(s: string) { - const f = await p4.opened(s); + // NB: preserves the user's checkbox selection — only NEW default files are + // auto-checked and vanished files are dropped, so a focus-refresh never + // silently re-checks files the user deselected before a commit. + // apply an already-fetched `p4 opened` result to the UI state + async function applyOpened(_s: string, f: OpenedFile[]) { setFiles(f); - const uncommitted = f.filter((x) => (x.change || "default") === "default"); - setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean))); + const defaults = f.filter((x) => (x.change || "default") === "default").map((x) => x.depotFile || "").filter(Boolean); + setChecked((prev) => { + const known = seenDefaults.current; + const next = new Set(); + for (const dp of defaults) { + // keep user's choice for files we've seen; auto-check genuinely new ones + if (!known.has(dp) || prev.has(dp)) next.add(dp); + } + seenDefaults.current = new Set(defaults); + return next; + }); setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null)); setSelRows(new Set()); setPreviewCL(null); try { setPending(await p4.changes()); } catch { setPending([]); } + void loadCollab(_s, f); + } + // who-else-has-these-files + pending conflicts (non-blocking; failures are silent) + async function loadCollab(s: string, mine: OpenedFile[]) { + const me = (info?.userName || "").toLowerCase(); + const myClient = (info?.clientName || "").toLowerCase(); + try { + const all = await p4.openedAll(s); + const map = new Map(); + for (const o of all) { + const dp = o.depotFile || ""; + if (!dp) continue; + const sameUser = (o.user || "").toLowerCase() === me; + const sameClient = (o.client || "").toLowerCase() === myClient; + if (sameUser && sameClient) continue; // it's my own checkout + // `p4 opened -a` includes an `ourLock` field only when the file is locked + const locked = (o as Record).ourLock !== undefined; + map.set(dp, { user: o.user || "?", locked }); + } + setOthers(map); + } catch { /* older server or no permission — skip indicators */ } + void mine; + try { setNeedResolve(await p4.resolveList()); } catch { setNeedResolve([]); } + } + const seenDefaults = useRef>(new Set()); // depot paths seen in a prior load + const refreshSeq = useRef(0); // guards against out-of-order refresh() results + const openChangeSeq = useRef(0); // guards against out-of-order describe() results + const committedDraft = useRef<{ s: string; b: string } | null>(null); // draft last moved into a commit + // clear the commit draft after Submit, but ONLY if the user hasn't started + // typing a new message since committing (so a fresh next-batch draft survives) + function clearDraftIfCommitted() { + const cd = committedDraft.current; + if (cd && cd.s === desc && cd.b === descBody) { setDesc(""); setDescBody(""); } + committedDraft.current = null; } // refresh the Changes view. `scan=true` also auto-detects out-of-band changes @@ -372,15 +513,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set // UI thread (async command) and never freezes the window. async function refresh(scope?: string, scan = false) { const s = scope !== undefined ? scope : activePath; + const seq = ++refreshSeq.current; // newest refresh wins; stale results are dropped setBusy(true); - try { await loadOpened(s); } - catch (e) { flash(String(e), true); } - finally { setBusy(false); } + try { const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); } + catch (e) { if (seq === refreshSeq.current) flash(String(e), true); } + finally { if (seq === refreshSeq.current) setBusy(false); } if (scan) { setScanning(true); - try { await p4.scan(s); await loadOpened(s); } - catch (e) { flash(String(e), true); } - finally { setScanning(false); } + try { await p4.scan(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); } + catch (e) { if (seq === refreshSeq.current) flash(String(e), true); } + finally { if (seq === refreshSeq.current) setScanning(false); } } } // Default = Perforce-native: just read what's checked out (p4 opened). With @@ -415,6 +557,17 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const id = setTimeout(() => setShowXfer(true), 400); return () => clearTimeout(id); }, [xferActive]); + // detect installed code editors once (workspace-independent) + useEffect(() => { p4.listEditors().then(setEditors).catch(() => setEditors([])); }, []); + // resolve the effective editor: chosen → VS Code → first real → system default + const curEditor = editors.find((e) => e.id === editorId && editorId) + || editors.find((e) => e.id === "vscode") + || editors.find((e) => e.id !== "default") + || editors[0]; + const effEditorId = curEditor?.id || "default"; + const effEditorName = curEditor?.name || t("System default"); + const chooseEditor = (id: string) => { setEditorId(id); setEditorPref(id); }; + // detect a UE project / .sln in the current working folder (Launch / Build actions) useEffect(() => { if (!activePath) { setUproject(""); setSlnPath(""); return; } @@ -446,6 +599,37 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set // eslint-disable-next-line }, [tab]); + // background poll: connection health (offline banner + auto-reconnect) and + // new-submit notifications from teammates. One lightweight timer for both. + useEffect(() => { + let alive = true; + const tick = async () => { + try { + const latest = await p4.latestChange(activePath); + if (!alive) return; + if (offline) { setOffline(false); flash(t("Reconnected to the server.")); refresh(); } + const n = (latest && latest.change) || ""; + if (n) { + 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()) { + flash(t("{user} submitted #{n}", { user: who, n })); + } + lastSubmit.current = n; + if (tab === "history") loadHistory(); + } + } + } catch { + if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner + } + }; + const id = window.setInterval(tick, 20000); + tick(); + return () => { alive = false; clearInterval(id); }; + // eslint-disable-next-line + }, [activePath, offline, tab]); + // drag & drop files/folders onto the window → reconcile them into the changelist useEffect(() => { let unlisten: (() => void) | undefined; @@ -483,13 +667,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set async function openChange(c: Change) { const n = c.change || ""; + const seq = ++openChangeSeq.current; // ignore a slow describe if another CL was picked setSelChange(n); // highlight the row immediately — no waiting for the load setHistFile(null); // leave any drilled-in file view setDetail(null); setDetailBusy(true); - try { setDetail(await p4.describe(n)); } - catch (e) { flash(String(e), true); } - finally { setDetailBusy(false); } + try { const d = await p4.describe(n); if (seq === openChangeSeq.current) setDetail(d); } + catch (e) { if (seq === openChangeSeq.current) flash(String(e), true); } + finally { if (seq === openChangeSeq.current) setDetailBusy(false); } } async function openWorkspaces() { @@ -533,12 +718,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set function switchTab(t: Tab) { setTab(t); setHistFile(null); - if (t === "history" && history.length === 0) loadHistory(); + if (t === "history") loadHistory(); // always refresh so it never shows a stale list } async function getLatest() { setBusy(true); - try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); } + try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); } // sync may pull new submitted changelists → refresh History too catch (e) { flash(String(e), true); setBusy(false); } } @@ -605,7 +790,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set try { const cl = await p4.commit(message, list); flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl })); - // keep the draft — a local commit is not final; it clears only on Submit (see pushAll / submitOne) + // keep the draft — a local commit is not final; it clears on Submit unless + // the user has meanwhile started a new message (see clearDraftIfCommitted) + committedDraft.current = { s: desc, b: descBody }; await refresh(); } catch (e) { flash(String(e), true); setBusy(false); } } @@ -638,7 +825,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); } } if (errors.length) flash(errors.join(" · "), true); - else { flash(t("Sent to the server: {n} changelist(s).", { n })); setDesc(""); setDescBody(""); } // draft done → clear on successful Submit + else { flash(t("Sent to the server: {n} changelist(s).", { n })); clearDraftIfCommitted(); } // draft done → clear on successful Submit await refresh(); await loadHistory(); // the submit created a new submitted changelist — refresh History } @@ -647,9 +834,25 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set async function submitOne(change: string) { if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return; setBusy(true); - try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); setDesc(""); setDescBody(""); await refresh(); await loadHistory(); } // draft clears on successful Submit + try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); } // draft clears on successful Submit catch (e) { flash(String(e), true); setBusy(false); } } + async function shelveCL(change: string) { + setBusy(true); + try { await p4.shelve(change); flash(t("Shelved #{n} on the server.", { n: change })); } + catch (e) { flash(String(e), true); } finally { setBusy(false); } + } + async function unshelveCL(change: string) { + setBusy(true); + try { await p4.unshelve(change); flash(t("Unshelved #{n} into the workspace.", { n: change })); await refresh(); } + catch (e) { flash(String(e), true); setBusy(false); } + } + async function deleteShelfCL(change: string) { + if (!(await confirm({ title: t("Delete shelved files?"), body: t("The shelved copy of #{n} on the server will be removed. Your local files are untouched.", { n: change }), confirm: t("Delete shelf"), danger: true }))) return; + setBusy(true); + try { await p4.deleteShelf(change); flash(t("Deleted shelved files of #{n}.", { n: change })); } + catch (e) { flash(String(e), true); } finally { setBusy(false); } + } // Uncommit: move a changelist's files back into the working set (default) async function uncommit(fs: OpenedFile[]) { const dps = fs.map((f) => f.depotFile || "").filter(Boolean); @@ -745,21 +948,61 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const idxs = multi ? [...selRows] : [i]; const targets = idxs.map((k) => view[k]).filter(Boolean) as OpenedFile[]; const dp = view[i]?.depotFile || ""; - const isCode = kindOf(splitPath(dp).name) === "code"; + const dps = targets.map((f) => f.depotFile || "").filter(Boolean); + const isCode = isCodeFile(splitPath(dp).name); openCtx(e, [ { label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) }, - ...(isCode ? [{ label: t("Edit in VS Code"), icon: I.vscode, act: () => { p4.openInVscode(dp).then(() => flash(t("Opening in VS Code…"))).catch((err) => flash(String(err), true)); } }] : []), + { label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) }, + { label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) }, + ...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("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []), { label: t("Open in Explorer"), act: () => reveal(dp) }, { label: t("Copy path"), act: () => copyText(dp, t("Path")) }, ]); } + async function lockFiles(dps: string[], lock: boolean) { + if (!dps.length) return; + try { await (lock ? p4.lock(dps) : p4.unlock(dps)); flash(lock ? t("Locked {n} file(s).", { n: dps.length }) : t("Unlocked {n} file(s).", { n: dps.length })); await refresh(); } + catch (e) { flash(String(e), true); } + } + async function moveToCL(change: string, dps: string[]) { + if (!change || !dps.length) return; + 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); } + } + // diff a historical revision against the one before it (History preview) + async function showDiffPrev(file: OpenedFile) { + const dp = file.depotFile || ""; + const rev = Number(file.rev || "0"); + if (!dp || rev <= 1) { flash(t("No earlier revision to compare."), true); return; } + try { + const text = await p4.diff2(`${dp}#${rev - 1}`, `${dp}#${rev}`); + setModal({ kind: "diff", title: `${splitPath(dp).name} #${rev - 1} ↔ #${rev}`, text: text || t("(files are identical)") }); + } catch (e) { flash(String(e), true); } + } + const showBlame = (spec: string, name: string) => setModal({ kind: "blame", spec, name }); + + async function doResolve(mode: "am" | "ay" | "at", file = "") { + try { + await p4.resolveFile(file, mode); + const rest = await p4.resolveList(); + setNeedResolve(rest); + if (!rest.length) { setResolveOpen(false); flash(t("All conflicts resolved.")); } + await refresh(); + } catch (e) { flash(String(e), true); } + } const menus: Record void }[]> = { File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }], Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }], - Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }], - Window: [{ label: (logOpen ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => setLogOpen((o) => !o) }], - Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: startBuild }, { label: t("People & Roles…"), act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }], + Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }], + Window: [ + { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => openDock("log") }, + { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", act: () => openDock("terminal") }, + ...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), act: () => openDock("unreal") }] : []), + ], + Tools: [{ label: t("Search depot…"), act: () => setModal({ kind: "search" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: startBuild }, { label: t("People & Roles…"), act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }], Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }], }; @@ -826,6 +1069,17 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set + {offline && ( +
+ {t("Disconnected from the server — retrying…")} +
+ )} + {needResolve.length > 0 && tab === "changes" && ( +
setResolveOpen(true)}> + {I.hex}{t("{n} file(s) need conflict resolution after sync.", { n: needResolve.length })} + +
+ )}
startResize(e, "left")} title={t("Drag to resize")} />
@@ -859,6 +1113,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set @@ -905,7 +1162,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set }
) : ( - + )}
@@ -936,28 +1193,34 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{tab === "changes" ? ( - + ) : ( -
- +
+ {histFile && (<>
{ e.preventDefault(); - const el = e.currentTarget; el.setPointerCapture(e.pointerId); 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 = () => { el.releasePointerCapture(e.pointerId); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); document.body.classList.remove("resizing"); }; - el.addEventListener("pointermove", move); el.addEventListener("pointerup", up); + 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 }} /> - + )}
)}
- {logOpen && setLogOpen(false)} onClear={() => setLogs([])} />} + {dockOpen && setDockOpen(false)} + height={dockH} onResize={startDockResize} + logs={logs} onClearLogs={() => setLogs([])} + termLines={termLines} termInput={termInput} setTermInput={setTermInput} termBusy={termBusy} + onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} uproject={uproject} />}
@@ -965,7 +1228,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"} - + {uproject && } + + {peek && logs.length > 0 && (
{t("Log")} · {logs.length}
@@ -983,8 +1248,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
)} {modal && modal.kind !== "users" && setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />} {modal?.kind === "users" && setModal(null)} onFlash={flash} />} + {modal?.kind === "search" && setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />} + {modal?.kind === "blame" && setModal(null)} onFlash={flash} />} + {modal?.kind === "diff" && setModal(null)} />} + {resolveOpen && setResolveOpen(false)} />} {ask && { ask.resolve(v); setAsk(null); }} />} {prompt && setPrompt(null)} />} {showXfer && transfer && } @@ -998,8 +1268,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set } /* ---------------- app modal (workspaces / scope / settings / about) ---------------- */ -function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoom, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }: - { modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) { +function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }: + { modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; editors: Editor[]; editorId: string; onPickEditor: (id: string) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) { useEffect(() => { const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", onKey); @@ -1087,7 +1357,14 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoo
{t("User")}{info?.userName || "—"}
{t("Workspace")}{info?.clientName || "—"}
{t("Server version")}{(info?.serverVersion as string || "").split("/")[2] || "—"}
- +
{t("Code editor")} + +
)} @@ -1117,30 +1394,6 @@ function Avatar({ user, name, size = 30, act }: { user: string; name?: string; s ); } -/* ---------------- AI settings block (inside Settings modal) — localStorage-backed ---------------- */ -function AiSettings() { - const [key, setKey] = useState(getKeyLocal()); - const [model, setModelVal] = useState(getModel()); - const [show, setShow] = useState(false); - return ( -
-
{I.spark}{t("AI commit messages")}
-
{t("OpenRouter key")} - - { setKey(e.target.value); setKeyLocal(e.target.value); }} /> - - -
-
{t("Model")} - { setModelVal(e.target.value); setModel(e.target.value); }} /> -
-
{t("Stored only on this machine. Get a key at openrouter.ai — a “:free” model costs nothing.")}
-
- ); -} - /* ---------------- People & Roles: who works on the depot + group/permission assignment ---------------- */ function UsersRoles({ me, onClose, onFlash }: { me: string; onClose: () => void; onFlash: (text: string, err?: boolean) => void }) { const [users, setUsers] = useState([]); @@ -1151,6 +1404,7 @@ function UsersRoles({ me, onClose, onFlash }: { me: string; onClose: () => void; const [myGroups, setMyGroups] = useState([]); // groups of the selected user const [gBusy, setGBusy] = useState(false); const [q, setQ] = useState(""); + const selSeq = useRef(0); // drop a slow groups() response if another user was picked useEffect(() => { let live = true; @@ -1172,10 +1426,11 @@ function UsersRoles({ me, onClose, onFlash }: { me: string; onClose: () => void; }, [onClose]); async function selectUser(u: string) { + const seq = ++selSeq.current; setSel(u); setMyGroups([]); setGBusy(true); - try { const g = await p4.groups(u); setMyGroups(g.map((x) => x.group || "").filter(Boolean)); } - catch (e) { onFlash(String(e), true); } - finally { setGBusy(false); } + try { const g = await p4.groups(u); if (seq === selSeq.current) setMyGroups(g.map((x) => x.group || "").filter(Boolean)); } + catch (e) { if (seq === selSeq.current) onFlash(String(e), true); } + finally { if (seq === selSeq.current) setGBusy(false); } } async function toggleGroup(group: string) { if (!sel) return; @@ -1293,24 +1548,137 @@ function LogRow({ l }: { l: LogEntry }) {
); } -function LogPanel({ logs, onClose, onClear }: { logs: LogEntry[]; onClose: () => void; onClear: () => void }) { +// common p4 subcommands for the terminal's autocomplete +const P4_COMMANDS = ["add","annotate","branch","branches","change","changes","client","clients","counter","counters","delete","depot","depots","describe","diff","dirs","edit","filelog","files","fstat","group","groups","have","info","integrate","labels","login","logout","monitor","move","opened","print","protect","reconcile","reopen","resolve","resolved","revert","reviews","shelve","sizes","status","stream","streams","submit","sync","tag","tickets","unshelve","user","users","where"]; + +/* ---------------- bottom dock: tabbed Log / Terminal / Unreal ---------------- */ +function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm, uproject }: { + tab: "log" | "terminal" | "unreal"; setTab: (t: "log" | "terminal" | "unreal") => void; onClose: () => void; + height: number; onResize: (e: ReactMouseEvent) => void; + logs: LogEntry[]; onClearLogs: () => void; + termLines: TermLine[]; termInput: string; setTermInput: (s: string) => void; termBusy: boolean; + onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void; uproject: string; +}) { + const tabs: { key: "log" | "terminal" | "unreal"; label: string; icon: ReactNode }[] = [ + { key: "log", label: t("Log"), icon: I.log }, + { key: "terminal", label: t("Terminal"), icon: I.terminal }, + ...(uproject ? [{ key: "unreal" as const, label: t("Unreal"), icon: I.hex }] : []), + ]; + return ( +
+
+
+
+ {tabs.map((tb) => ( + + ))} +
+ + {(tab === "log" || tab === "terminal") && } + +
+ {tab === "log" && } + {tab === "terminal" && } + {tab === "unreal" && } +
+ ); +} + +function LogTab({ logs }: { logs: LogEntry[] }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]); return ( -
-
- {I.log}Log{logs.length} - - +
+ {logs.length === 0 ?
{t("No commands yet.")}
+ : logs.map((l) => )} +
+ ); +} + +/* p4 console with command autocomplete + ↑/↓ history */ +function TerminalTab({ lines, input, setInput, busy, onRun, cmdHist }: { + lines: TermLine[]; input: string; setInput: (s: string) => void; busy: boolean; onRun: (cmd: string) => void; cmdHist: string[]; +}) { + const bodyRef = useRef(null); + const inputRef = useRef(null); + const [hi, setHi] = useState(-1); // history cursor (-1 = live input) + const [sugIdx, setSugIdx] = useState(0); + useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [lines, busy]); + useEffect(() => { setSugIdx(0); }, [input]); + + // suggest only while typing the first bare word (the p4 subcommand) + const firstWord = input.trim().split(/\s+/)[0] || ""; + const typing = input.trim() === firstWord && firstWord.length > 0 && !input.endsWith(" "); + const sugs = typing ? P4_COMMANDS.filter((c) => c.startsWith(firstWord.toLowerCase()) && c !== firstWord.toLowerCase()).slice(0, 8) : []; + + function accept(cmd: string) { setInput(cmd + " "); inputRef.current?.focus(); } + function onKeyDown(e: ReactKeyboardEvent) { + if (sugs.length) { + if (e.key === "Tab") { e.preventDefault(); accept(sugs[sugIdx] || sugs[0]); return; } + if (e.key === "ArrowDown") { e.preventDefault(); setSugIdx((i) => (i + 1) % sugs.length); return; } + if (e.key === "ArrowUp") { e.preventDefault(); setSugIdx((i) => (i - 1 + sugs.length) % sugs.length); return; } + } + if (e.key === "Enter") { e.preventDefault(); if (!busy) onRun(input); setHi(-1); return; } + if (e.key === "ArrowUp") { e.preventDefault(); const ni = hi < 0 ? cmdHist.length - 1 : Math.max(0, hi - 1); if (cmdHist[ni] != null) { setHi(ni); setInput(cmdHist[ni]); } return; } + if (e.key === "ArrowDown") { e.preventDefault(); if (hi < 0) return; const ni = hi + 1; if (ni >= cmdHist.length) { setHi(-1); setInput(""); } else { setHi(ni); setInput(cmdHist[ni]); } return; } + } + + return ( +
+
+ {lines.length === 0 &&
{t("p4 console — type a command (e.g. opened, changes -m 5, info). ↑/↓ recalls history, Tab completes.")}
} + {lines.map((ln) => ( +
+ {ln.cmd &&
p4> {ln.cmd}
} + {ln.running ?
{t("running…")}
+ :
{ln.text}
} +
+ ))}
-
- {logs.length === 0 ?
{t("No commands yet.")}
- : logs.map((l) => )} +
+ p4> + setInput(e.target.value)} onKeyDown={onKeyDown} /> + {busy && } + {sugs.length > 0 && ( +
+ {sugs.map((s, i) => ( +
{ e.preventDefault(); accept(s); }}> + {firstWord}{s.slice(firstWord.length)} +
+ ))} +
+ )}
); } +/* tails the newest Unreal log (Saved/Logs/*.log) while the tab is open */ +function UnrealTab({ uproject }: { uproject: string }) { + const [text, setText] = useState(""); + const [err, setErr] = useState(""); + const ref = useRef(null); + useEffect(() => { + let live = true; + const load = () => p4.ueLog(uproject).then((s) => { if (live) { setText(s); setErr(""); } }).catch((e) => live && setErr(String(e))); + load(); + const iv = window.setInterval(load, 1500); + return () => { live = false; clearInterval(iv); }; + }, [uproject]); + useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [text]); + return ( +
+ {err ?
{err}
+ : text ?
{text}
+ :
{t("No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.")}
} +
+ ); +} + /* ---------------- server transfer progress (P4V-style) ---------------- */ function TransferDialog({ transfer }: { transfer: Transfer }) { const up = transfer.op === "submit"; @@ -1443,8 +1811,12 @@ function TextPrompt({ title, label, value, onSave, onClose }: { title: string; l /* ---------------- confirm modal ---------------- */ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) { + const btn = useRef(null); useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(false); if (e.key === "Enter") onClose(true); }; + btn.current?.focus(); + // only Escape is global; confirming requires the focused button (Enter/Space + // on it, or a click) so a stray Enter never triggers a destructive action + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(false); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [onClose]); @@ -1460,7 +1832,144 @@ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string

{body}

- + +
+
+
+ ); +} + +/* ---------------- depot file search ---------------- */ +function SearchModal({ scope, onClose, onOpenEditor, onReveal, onCopy }: { scope: string; onClose: () => void; onOpenEditor: (dp: string) => void; onReveal: (dp: string) => void; onCopy: (dp: string) => void }) { + const [q, setQ] = useState(""); + const [res, setRes] = useState<{ depotFile?: string; rev?: string }[]>([]); + const [busy, setBusy] = useState(false); + const seq = useRef(0); + const inp = useRef(null); + useEffect(() => { inp.current?.focus(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]); + useEffect(() => { + const s = ++seq.current; + if (q.trim().length < 2) { setRes([]); setBusy(false); return; } + setBusy(true); + const id = setTimeout(() => { + p4.searchFiles(q.trim(), scope).then((r) => { if (s === seq.current) { setRes(r); setBusy(false); } }).catch(() => { if (s === seq.current) { setRes([]); setBusy(false); } }); + }, 250); // debounce + return () => clearTimeout(id); + }, [q, scope]); + return ( +
+
e.stopPropagation()}> +
{I.search}

{t("Search depot")}

+ {scope || "//…"} + +
+
{I.search} setQ(e.target.value)} />
+
+ {busy &&
{t("Searching…")}
} + {!busy && q.trim().length >= 2 && res.length === 0 &&
{t("No matches.")}
} + {res.map((f) => { + const dp = f.depotFile || ""; + const sp = splitPath(dp); + return ( +
{ e.preventDefault(); }}> + {sp.name}{sp.dir} + {isCodeFile(sp.name) && } + + +
+ ); + })} +
+
+
+ ); +} + +/* ---------------- blame / annotate ---------------- */ +function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) { + const [text, setText] = useState(null); + useEffect(() => { + let live = true; + p4.annotate(spec).then((s) => live && setText(s)).catch((e) => { if (live) { onFlash(String(e), true); setText(""); } }); + const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", k); + return () => { live = false; document.removeEventListener("keydown", k); }; + }, [spec]); // eslint-disable-line + // annotate lines look like: "12345: " — split the prefix for coloring + const rows = (text || "").split("\n").map((ln, i) => { + const m = ln.match(/^(\d+):\s*(\S+)?\s?(.*)$/); + return m ? { n: i + 1, cl: m[1], user: m[2] || "", code: m[3] ?? "" } : { n: i + 1, cl: "", user: "", code: ln }; + }); + return ( +
+
e.stopPropagation()}> +
{I.people}

{t("Blame")}

{name} + +
+
+ {text === null ?
{t("Loading…")}
+ : rows.map((r) => ( +
+ {r.cl && "#" + r.cl} + {r.user} + {r.code || " "} +
+ ))} +
+
+
+ ); +} + +/* ---------------- unified-diff viewer (diff2) ---------------- */ +function DiffModal({ title, text, onClose }: { title: string; text: string; onClose: () => void }) { + useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]); + const lines = text.split("\n"); + return ( +
+
e.stopPropagation()}> +
{I.clock}

{t("Diff")}

{title} + +
+
+ {lines.map((ln, i) => { + const c = ln.startsWith("+") && !ln.startsWith("+++") ? "add" : ln.startsWith("-") && !ln.startsWith("---") ? "del" : ln.startsWith("@@") ? "hunk" : ""; + return
{ln || " "}
; + })} +
+
+
+ ); +} + +/* ---------------- resolve conflicts ---------------- */ +function ResolveModal({ files, onResolve, onClose }: { files: OpenedFile[]; onResolve: (mode: "am" | "ay" | "at", file?: string) => void; onClose: () => void }) { + useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]); + return ( +
+
e.stopPropagation()}> +
{I.hex}

{t("Resolve conflicts")}

{t("{n} file(s)", { n: files.length })} + +
+
+ + + +
+
+ {files.length === 0 &&
{t("Nothing to resolve.")}
} + {files.map((f) => { + const dp = f.depotFile || ""; + const sp = splitPath(dp); + return ( +
+ {sp.name}{sp.dir} + + + +
+ ); + })}
@@ -1469,7 +1978,7 @@ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string /* ---------------- change detail (History right panel) — virtualized ---------------- */ const CROW = 52; -function ChangeDetail({ detail, busy, onOpen, selected, split }: { detail: Describe | null; busy?: boolean; onOpen?: (f: OpenedFile) => void; selected?: string; split?: boolean }) { +function ChangeDetail({ detail, busy, onOpen, selected, split, width }: { detail: Describe | null; busy?: boolean; onOpen?: (f: OpenedFile) => void; selected?: string; split?: boolean; width?: number }) { const ref = useRef(null); const [scroll, setScroll] = useState(0); const [h, setH] = useState(500); @@ -1486,8 +1995,10 @@ function ChangeDetail({ detail, busy, onOpen, selected, split }: { detail: Descr useEffect(() => { if (ref.current) ref.current.scrollTop = 0; setScroll(0); }, [detail?.change]); const cls = "right" + (split ? " histlist" : ""); - if (busy) return
{t("Loading file list…")}
; - if (!detail) return
{I.clock}{t("Select a changelist in History")}
; + // width set → fixed, resizable column (direct inline style, no CSS-var indirection) + const wstyle: CSSProperties | undefined = width != null ? { width, flex: "none" } : undefined; + if (busy) return
{t("Loading file list…")}
; + if (!detail) return
{I.clock}{t("Select a changelist in History")}
; const first = Math.max(0, Math.floor(scroll / CROW) - 6); const last = Math.min(fileRows.length, Math.ceil((scroll + h) / CROW) + 6); @@ -1507,7 +2018,7 @@ function ChangeDetail({ detail, busy, onOpen, selected, split }: { detail: Descr ); } return ( -
+
#{detail.change} @@ -1573,8 +2084,8 @@ function Thumb({ file }: { file: OpenedFile }) { /* ---------------- virtualized file list ---------------- */ const ROW = 52; -function FileList({ files, sel, selRows, checked, onRowClick, onToggle, onContext }: - { files: OpenedFile[]; sel: number | null; selRows: Set; checked: Set; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void; onContext: (i: number, e: ReactMouseEvent) => void }) { +function FileList({ files, sel, selRows, checked, others, onRowClick, onToggle, onContext }: + { files: OpenedFile[]; sel: number | null; selRows: Set; checked: Set; others: Map; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void; onContext: (i: number, e: ReactMouseEvent) => void }) { const ref = useRef(null); const [scroll, setScroll] = useState(0); const [h, setH] = useState(500); @@ -1595,11 +2106,13 @@ function FileList({ files, sel, selRows, checked, onRowClick, onToggle, onContex const { name, dir } = splitPath(dp); const st = statusOf(f.action); const isCk = checked.has(dp); + const other = others.get(dp); rows.push(
onRowClick(i, e)} onContextMenu={(e) => onContext(i, e)}> { e.stopPropagation(); onToggle(i, dp); }}>{I.check} {name}{dir} + {other && {other.locked ? I.lock : I.people}{other.user}} {st.ch}
); @@ -1608,7 +2121,16 @@ function FileList({ files, sel, selRows, checked, onRowClick, onToggle, onContex } /* ---------------- preview panel ---------------- */ -function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void }) { +function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void }) { + const [explain, setExplain] = useState(null); // AI code summary + const [explBusy, setExplBusy] = useState(false); + const explSeq = useRef(0); // drop a slow AI response if the file changed meanwhile + // on file change show its cached summary (persisted; removed only via ×) + useEffect(() => { + explSeq.current++; + setExplain(file ? getExplain(file._spec || file.depotFile || "") : null); + setExplBusy(false); + }, [file?.depotFile, file?._spec]); if (!file) return
{I.cube}{t("Select a file on the left")}
; const dp = file.depotFile || ""; const spec = file._spec || ""; // set → read-only view of a submitted revision @@ -1616,9 +2138,31 @@ function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f: const { name, dir } = splitPath(dp); const st = statusOf(file.action); const kind = kindOf(name); + const codeFile = isCodeFile(name); // gate Summary/IDE buttons to real source files const rev = hist ? "#" + (file.rev || "?") : file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?"); + // ask the AI for a short description of what this source file does + async function doExplain() { + if (!file) return; + const seq = ++explSeq.current; + const key = spec || dp; + setExplBusy(true); setExplain(null); + try { + const buf = await fileBytes(file); + // guard against binaries slipping through: bail if it's not decodable text + const bytes = new Uint8Array(buf); + if (bytes.length && bytes.slice(0, 8000).includes(0)) throw new Error(t("Not a text file.")); + const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes); + const out = await aiExplainCode(name, text); + if (seq !== explSeq.current) return; // a different file is showing now + setExplain(out); + saveExplain(key, out); // persist until dismissed via × + } catch (e) { + if (seq === explSeq.current) onFlash(String(e), true); + } finally { if (seq === explSeq.current) setExplBusy(false); } + } + return (
@@ -1628,15 +2172,35 @@ function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f: {file.action} · {rev}{file.type ? " · " + file.type : ""}{hist && file.change ? " · " + t("in #{n}", { n: file.change }) : ""} - {kind === "code" && !hist && ( - { try { await p4.openInVscode(dp); onFlash(t("Opening in VS Code…")); } catch (e) { onFlash(String(e), true); } }}> - {I.vscode}{t("Edit in VS Code")} + {codeFile && ( + !explBusy && doExplain()} title={t("AI summary — what this file does")}> + {explBusy ? : I.spark} )} + {codeFile && ( + { try { await p4.openInEditor(dp, editorId); onFlash(t("Opening in {editor}…", { editor: editorName })); } catch (e) { onFlash(String(e), true); } }}> + {I.vscode} + + )} + {codeFile && onBlame && ( + onBlame(spec || dp, name)}>{I.people} + )} + {hist && onDiffPrev && ( + onDiffPrev(file)}>{I.clock} + )} {!hist && onRevert(file)}>{I.revert}{t("Revert")}}
+ {(explain || explBusy) && ( +
+ {I.spark} +
{explBusy ? {t("Reading the code…")} : explain}
+ {!explBusy && } +
+ )} + {kind === "model" ? : kind === "image" ? : kind === "uasset" ? diff --git a/src/ai.ts b/src/ai.ts index 0bece3f..dc521e7 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,37 +1,33 @@ // OpenRouter-powered commit summary + small avatar helpers. import { p4, OpenedFile, statusOf } from "./p4"; +import { LANG } from "./i18n"; -const MODEL_KEY = "exd-ai-model"; -const KEY_KEY = "exd-ai-key"; -// cheap + reliable default; change to a "…:free" model in Settings to pay nothing +// AI replies follow the app's UI language +const LANG_NAMES: Record = { en: "English", ru: "Russian", de: "German", fr: "French", es: "Spanish" }; +function langName(): string { return LANG_NAMES[LANG] || "English"; } + +// fixed model — no user-facing AI settings export const DEFAULT_MODEL = "openai/gpt-4o-mini"; -export function getModel(): string { try { return localStorage.getItem(MODEL_KEY) || DEFAULT_MODEL; } catch { return DEFAULT_MODEL; } } -export function setModel(m: string) { try { localStorage.setItem(MODEL_KEY, m.trim() || DEFAULT_MODEL); } catch {} } -export function getKeyLocal(): string { try { return localStorage.getItem(KEY_KEY) || ""; } catch { return ""; } } -export function setKeyLocal(k: string) { try { localStorage.setItem(KEY_KEY, k.trim()); } catch {} } - -// key resolution: localStorage first, else the app-config file (git-ignored) +// the key is baked into the binary at build time (src-tauri/openrouter.key) export async function resolveKey(): Promise { - const local = getKeyLocal(); - if (local) return local; try { return (await p4.readAiKey()) || ""; } catch { return ""; } } // generate a commit summary + description from the changed files export async function aiSummary(files: OpenedFile[]): Promise<{ summary: string; description: string }> { const key = await resolveKey(); - if (!key) throw new Error("No OpenRouter API key — set it in Settings"); + if (!key) throw new Error("No OpenRouter API key baked into this build"); const list = files.slice(0, 200).map((f) => `${statusOf(f.action).ch} ${f.depotFile || ""}`).join("\n"); const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Title": "Exbyte Depot" }, body: JSON.stringify({ - model: getModel(), + model: DEFAULT_MODEL, max_tokens: 300, temperature: 0.3, messages: [ - { role: "system", content: "You write concise commit messages for a Perforce/Unreal project. Given a list of changed files (+ added, ± edited, − deleted), reply with ONE short imperative summary line (under 72 chars, no trailing period), then a blank line, then an optional 1-3 line description. Plain text only — no markdown, no code fences, no quotes." }, + { role: "system", content: `You write concise commit messages for a Perforce/Unreal project. Given a list of changed files (+ added, ± edited, − deleted), reply with ONE short imperative summary line (under 72 chars, no trailing period), then a blank line, then an optional 1-3 line description. Plain text only — no markdown, no code fences, no quotes. Write the entire reply in ${langName()}.` }, { role: "user", content: `Changed files:\n${list}` }, ], }), @@ -49,6 +45,53 @@ export async function aiSummary(files: OpenedFile[]): Promise<{ summary: string; return { summary, description }; } +// briefly explain what a source file does (2–4 sentences, plain text) +export async function aiExplainCode(name: string, code: string): Promise { + const key = await resolveKey(); + if (!key) throw new Error("No OpenRouter API key baked into this build"); + const snippet = code.slice(0, 14000); // cap the payload + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Title": "Exbyte Depot" }, + body: JSON.stringify({ + model: DEFAULT_MODEL, + max_tokens: 260, + temperature: 0.2, + messages: [ + { role: "system", content: `You explain source files for a Perforce/Unreal C++ (and general game) project. Given one file, reply with a SHORT plain-text summary (2–4 sentences) of what it does and its role in the project. No markdown, no code fences, no headings, no bullet points — a single tight paragraph. Write the entire reply in ${langName()}.` }, + { role: "user", content: `File: ${name}\n\n${snippet}` }, + ], + }), + }); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`OpenRouter ${res.status}: ${txt.slice(0, 180)}`); + } + const data = await res.json(); + const content: string = (data?.choices?.[0]?.message?.content || "").trim(); + if (!content) throw new Error("Empty AI response"); + return content; +} + +// --- persistent cache of AI code summaries (removed only via the × button) --- +const EXPL_KEY = "exd-explain"; +type ExplEntry = { k: string; v: string }; +function loadExpl(): ExplEntry[] { + try { const r = localStorage.getItem(EXPL_KEY); const l = r ? JSON.parse(r) : []; return Array.isArray(l) ? l : []; } catch { return []; } +} +function storeExpl(list: ExplEntry[]) { try { localStorage.setItem(EXPL_KEY, JSON.stringify(list)); } catch {} } +export function getExplain(key: string): string | null { + return loadExpl().find((e) => e.k === key)?.v ?? null; +} +export function saveExplain(key: string, text: string) { + const list = loadExpl().filter((e) => e.k !== key); + list.push({ k: key, v: text }); + storeExpl(list.slice(-80)); // keep the 80 most recent +} +export function dropExplain(key: string) { + storeExpl(loadExpl().filter((e) => e.k !== key)); +} + // --- avatar helpers --- export function initials(name: string, user: string): string { const n = (name || "").trim(); diff --git a/src/i18n.ts b/src/i18n.ts index 1435d44..6d12fb8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -75,6 +75,18 @@ const D: Record = { "No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez d’abord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." }, "Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" }, "AI": { ru: "ИИ", de: "KI", fr: "IA", es: "IA" }, + "Terminal": { ru: "Терминал", de: "Terminal", fr: "Terminal", es: "Terminal" }, + "Summary": { ru: "Описание", de: "Zusammenfassung", fr: "Résumé", es: "Resumen" }, + "AI summary — what this file does": { ru: "ИИ-описание — что делает этот файл", de: "KI-Zusammenfassung — was diese Datei tut", fr: "Résumé IA — ce que fait ce fichier", es: "Resumen con IA — qué hace este archivo" }, + "Reading the code…": { ru: "Читаю код…", de: "Code wird gelesen…", fr: "Lecture du code…", es: "Leyendo el código…" }, + "Dismiss": { ru: "Закрыть", de: "Schließen", fr: "Fermer", es: "Cerrar" }, + "Unreal": { ru: "Unreal", de: "Unreal", fr: "Unreal", es: "Unreal" }, + "Unreal Log": { ru: "Лог Unreal", de: "Unreal-Log", fr: "Journal Unreal", es: "Registro de Unreal" }, + "(no output)": { ru: "(нет вывода)", de: "(keine Ausgabe)", fr: "(aucune sortie)", es: "(sin salida)" }, + "running…": { ru: "выполняется…", de: "läuft…", fr: "en cours…", es: "ejecutando…" }, + "type a p4 command…": { ru: "введите команду p4…", de: "p4-Befehl eingeben…", fr: "tapez une commande p4…", es: "escribe un comando p4…" }, + "p4 console — type a command (e.g. opened, changes -m 5, info). ↑/↓ recalls history, Tab completes.": { ru: "Консоль p4 — введите команду (например: opened, changes -m 5, info). ↑/↓ — история, Tab — автодополнение.", de: "p4-Konsole — Befehl eingeben (z. B. opened, changes -m 5, info). ↑/↓ Verlauf, Tab vervollständigt.", fr: "Console p4 — tapez une commande (ex. opened, changes -m 5, info). ↑/↓ historique, Tab complète.", es: "Consola p4 — escribe un comando (p. ej. opened, changes -m 5, info). ↑/↓ historial, Tab autocompleta." }, + "No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.": { ru: "Лога Unreal пока нет. Он появится здесь, когда редактор пишет в Saved/Logs — то есть пока Unreal запущен.", de: "Noch kein Unreal-Log. Es erscheint hier, sobald der Editor nach Saved/Logs schreibt — also während Unreal läuft.", fr: "Aucun journal Unreal pour l’instant. Il apparaît ici quand l’éditeur écrit dans Saved/Logs, c.-à-d. quand Unreal tourne.", es: "Aún no hay registro de Unreal. Aparece aquí cuando el editor escribe en Saved/Logs, es decir, mientras Unreal se ejecuta." }, "AI commit messages": { ru: "ИИ-сообщения коммитов", de: "KI-Commit-Nachrichten", fr: "Messages de commit IA", es: "Mensajes de commit con IA" }, "Draft a commit message with AI from the selected files": { ru: "Сгенерировать сообщение коммита из выбранных файлов через ИИ", de: "Commit-Nachricht per KI aus den gewählten Dateien entwerfen", fr: "Rédiger un message de commit par IA à partir des fichiers sélectionnés", es: "Redactar un mensaje de commit con IA a partir de los archivos seleccionados" }, "Select some files first.": { ru: "Сначала выбери файлы.", de: "Wähle zuerst Dateien aus.", fr: "Sélectionnez d’abord des fichiers.", es: "Selecciona primero algunos archivos." }, @@ -190,7 +202,13 @@ const D: Record = { "Local changes to {name} will be lost permanently — the file returns to the server version.": { ru: "Локальные изменения в {name} будут потеряны безвозвратно — файл вернётся к версии с сервера.", de: "Lokale Änderungen an {name} gehen endgültig verloren — die Datei kehrt zur Serverversion zurück.", fr: "Les modifications locales de {name} seront perdues définitivement — le fichier revient à la version du serveur.", es: "Los cambios locales en {name} se perderán para siempre — el archivo vuelve a la versión del servidor." }, "Discarded: {name}": { ru: "Отменено: {name}", de: "Verworfen: {name}", fr: "Abandonné : {name}", es: "Descartado: {name}" }, "Edit in VS Code": { ru: "Открыть в VS Code", de: "In VS Code öffnen", fr: "Ouvrir dans VS Code", es: "Abrir en VS Code" }, + "Open in VS Code": { ru: "Открыть в VS Code", de: "In VS Code öffnen", fr: "Ouvrir dans VS Code", es: "Abrir en VS Code" }, "Opening in VS Code…": { ru: "Открываю в VS Code…", de: "Wird in VS Code geöffnet…", fr: "Ouverture dans VS Code…", es: "Abriendo en VS Code…" }, + "Open in {editor}": { ru: "Открыть в {editor}", de: "In {editor} öffnen", fr: "Ouvrir dans {editor}", es: "Abrir en {editor}" }, + "Opening in {editor}…": { ru: "Открываю в {editor}…", de: "Wird in {editor} geöffnet…", fr: "Ouverture dans {editor}…", es: "Abriendo en {editor}…" }, + "Code editor": { ru: "Редактор кода", de: "Code-Editor", fr: "Éditeur de code", es: "Editor de código" }, + "Open code files in this editor": { ru: "Открывать файлы кода в этом редакторе", de: "Code-Dateien in diesem Editor öffnen", fr: "Ouvrir les fichiers de code dans cet éditeur", es: "Abrir archivos de código en este editor" }, + "System default": { ru: "Системный по умолчанию", de: "Systemstandard", fr: "Application par défaut", es: "Predeterminado del sistema" }, "Revert changelist #{n}?": { ru: "Откатить changelist #{n}?", de: "Changelist #{n} zurücksetzen?", fr: "Annuler le changelist #{n} ?", es: "¿Revertir changelist #{n}?" }, "Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”": { ru: "Perforce создаст НОВЫЙ changelist, отменяющий изменения из #{n}, и засабмитит его на сервер. История сохранится (как «Revert» в GitHub Desktop).\n\n«{desc}»", de: "Perforce erstellt eine NEUE Changelist, die #{n} rückgängig macht, und sendet sie an den Server. Der Verlauf bleibt erhalten (wie „Revert“ in GitHub Desktop).\n\n„{desc}“", fr: "Perforce créera un NOUVEAU changelist qui annule #{n} et l’enverra au serveur. L’historique est conservé (comme « Revert » dans GitHub Desktop).\n\n« {desc} »", es: "Perforce creará un NUEVO changelist que deshace #{n} y lo enviará al servidor. El historial se conserva (como «Revert» en GitHub Desktop).\n\n«{desc}»" }, "Revert #{n}": { ru: "Откатить #{n}", de: "#{n} zurücksetzen", fr: "Annuler #{n}", es: "Revertir #{n}" }, @@ -308,6 +326,55 @@ const D: Record = { "Downloading… {pct}%": { ru: "Загрузка… {pct}%", de: "Wird geladen… {pct}%", fr: "Téléchargement… {pct}%", es: "Descargando… {pct}%" }, "Installed — restarting…": { ru: "Установлено — перезапускаю…", de: "Installiert — Neustart…", fr: "Installé — redémarrage…", es: "Instalado — reiniciando…" }, "Update now": { ru: "Обновить сейчас", de: "Jetzt aktualisieren", fr: "Mettre à jour", es: "Actualizar ahora" }, + + // collaboration / advanced perforce + "Lock (exclusive)": { ru: "Заблокировать (эксклюзивно)", de: "Sperren (exklusiv)", fr: "Verrouiller (exclusif)", es: "Bloquear (exclusivo)" }, + "Unlock": { ru: "Разблокировать", de: "Entsperren", fr: "Déverrouiller", es: "Desbloquear" }, + "Locked {n} file(s).": { ru: "Заблокировано файлов: {n}.", de: "{n} Datei(en) gesperrt.", fr: "{n} fichier(s) verrouillé(s).", es: "{n} archivo(s) bloqueado(s)." }, + "Unlocked {n} file(s).": { ru: "Разблокировано файлов: {n}.", de: "{n} Datei(en) entsperrt.", fr: "{n} fichier(s) déverrouillé(s).", es: "{n} archivo(s) desbloqueado(s)." }, + "Locked by {u}": { ru: "Заблокировал {u}", de: "Gesperrt von {u}", fr: "Verrouillé par {u}", es: "Bloqueado por {u}" }, + "Also open by {u}": { ru: "Также открыт у {u}", de: "Auch offen bei {u}", fr: "Aussi ouvert par {u}", es: "También abierto por {u}" }, + "Move to #{n}": { ru: "Переместить в #{n}", de: "Verschieben nach #{n}", fr: "Déplacer vers #{n}", es: "Mover a #{n}" }, + "Moved {n} file(s) to #{c}.": { ru: "Перемещено файлов {n} в #{c}.", de: "{n} Datei(en) nach #{c} verschoben.", fr: "{n} fichier(s) déplacé(s) vers #{c}.", es: "{n} archivo(s) movido(s) a #{c}." }, + "Blame (annotate)": { ru: "Авторы строк (annotate)", de: "Zeilen-Autoren (annotate)", fr: "Blâme (annotate)", es: "Autoría (annotate)" }, + "Blame": { ru: "Авторы строк", de: "Zeilen-Autoren", fr: "Blâme", es: "Autoría" }, + "Blame — who changed each line": { ru: "Авторы строк — кто менял каждую строку", de: "Wer welche Zeile geändert hat", fr: "Qui a modifié chaque ligne", es: "Quién cambió cada línea" }, + "Diff against the previous revision": { ru: "Сравнить с предыдущей ревизией", de: "Mit vorheriger Revision vergleichen", fr: "Comparer à la révision précédente", es: "Comparar con la revisión anterior" }, + "No earlier revision to compare.": { ru: "Нет предыдущей ревизии для сравнения.", de: "Keine frühere Revision zum Vergleichen.", fr: "Aucune révision antérieure à comparer.", es: "No hay revisión anterior que comparar." }, + "(files are identical)": { ru: "(файлы идентичны)", de: "(Dateien sind identisch)", fr: "(fichiers identiques)", es: "(archivos idénticos)" }, + "Shelve (share on server)": { ru: "Отложить (shelve, на сервере)", de: "Shelve (auf Server teilen)", fr: "Remiser (shelve, sur serveur)", es: "Archivar (shelve, en servidor)" }, + "Unshelve into workspace": { ru: "Вернуть из shelve в рабочую копию", de: "Aus Shelve zurückholen", fr: "Récupérer depuis le shelve", es: "Recuperar del shelve" }, + "Delete shelved files": { ru: "Удалить отложенные файлы", de: "Shelve-Dateien löschen", fr: "Supprimer les fichiers remisés", es: "Eliminar archivos archivados" }, + "Shelved #{n} on the server.": { ru: "Отложено #{n} на сервере.", de: "#{n} auf dem Server geshelved.", fr: "#{n} remisé sur le serveur.", es: "#{n} archivado en el servidor." }, + "Unshelved #{n} into the workspace.": { ru: "Возвращено #{n} в рабочую копию.", de: "#{n} in den Arbeitsbereich zurückgeholt.", fr: "#{n} récupéré dans l'espace de travail.", es: "#{n} recuperado al espacio de trabajo." }, + "Delete shelved files?": { ru: "Удалить отложенные файлы?", de: "Shelve-Dateien löschen?", fr: "Supprimer les fichiers remisés ?", es: "¿Eliminar archivos archivados?" }, + "The shelved copy of #{n} on the server will be removed. Your local files are untouched.": { ru: "Отложенная копия #{n} на сервере будет удалена. Локальные файлы не тронуты.", de: "Die Shelve-Kopie von #{n} auf dem Server wird entfernt. Lokale Dateien bleiben unberührt.", fr: "La copie remisée de #{n} sur le serveur sera supprimée. Vos fichiers locaux sont intacts.", es: "La copia archivada de #{n} en el servidor se eliminará. Tus archivos locales quedan intactos." }, + "Delete shelf": { ru: "Удалить shelve", de: "Shelve löschen", fr: "Supprimer le shelve", es: "Eliminar shelve" }, + "Deleted shelved files of #{n}.": { ru: "Удалены отложенные файлы #{n}.", de: "Shelve-Dateien von #{n} gelöscht.", fr: "Fichiers remisés de #{n} supprimés.", es: "Archivos archivados de #{n} eliminados." }, + "Search depot…": { ru: "Поиск в депо…", de: "Depot durchsuchen…", fr: "Rechercher dans le dépôt…", es: "Buscar en el depósito…" }, + "Search depot": { ru: "Поиск в депо", de: "Depot durchsuchen", fr: "Recherche dans le dépôt", es: "Buscar en el depósito" }, + "Type at least 2 characters…": { ru: "Введите минимум 2 символа…", de: "Mindestens 2 Zeichen eingeben…", fr: "Saisissez au moins 2 caractères…", es: "Escribe al menos 2 caracteres…" }, + "Searching…": { ru: "Поиск…", de: "Suche…", fr: "Recherche…", es: "Buscando…" }, + "Open in editor": { ru: "Открыть в редакторе", de: "Im Editor öffnen", fr: "Ouvrir dans l'éditeur", es: "Abrir en el editor" }, + "Resolve conflicts": { ru: "Разрешить конфликты", de: "Konflikte auflösen", fr: "Résoudre les conflits", es: "Resolver conflictos" }, + "Resolve conflicts ({n})": { ru: "Разрешить конфликты ({n})", de: "Konflikte auflösen ({n})", fr: "Résoudre les conflits ({n})", es: "Resolver conflictos ({n})" }, + "Resolve…": { ru: "Разрешить…", de: "Auflösen…", fr: "Résoudre…", es: "Resolver…" }, + "{n} file(s) need conflict resolution after sync.": { ru: "Файлов с конфликтами после синка: {n}.", de: "{n} Datei(en) benötigen nach Sync eine Konfliktauflösung.", fr: "{n} fichier(s) nécessitent une résolution après la synchro.", es: "{n} archivo(s) necesitan resolución tras la sincronización." }, + "Auto-merge all": { ru: "Авто-слияние всех", de: "Alle automatisch zusammenführen", fr: "Fusion auto de tout", es: "Fusión automática de todo" }, + "Accept theirs (all)": { ru: "Принять их (все)", de: "Ihre übernehmen (alle)", fr: "Accepter les leurs (tout)", es: "Aceptar los suyos (todo)" }, + "Accept yours (all)": { ru: "Принять свои (все)", de: "Meine übernehmen (alle)", fr: "Accepter les miens (tout)", es: "Aceptar los míos (todo)" }, + "Auto-merge": { ru: "Авто-слияние", de: "Auto-Merge", fr: "Fusion auto", es: "Fusión automática" }, + "Accept theirs": { ru: "Принять их", de: "Ihre übernehmen", fr: "Accepter les leurs", es: "Aceptar los suyos" }, + "Accept yours": { ru: "Принять свои", de: "Meine übernehmen", fr: "Accepter les miens", es: "Aceptar los míos" }, + "Merge": { ru: "Слить", de: "Zusammenführen", fr: "Fusionner", es: "Fusionar" }, + "Theirs": { ru: "Их", de: "Ihre", fr: "Les leurs", es: "Suyos" }, + "Yours": { ru: "Свои", de: "Meine", fr: "Les miens", es: "Míos" }, + "All conflicts resolved.": { ru: "Все конфликты разрешены.", de: "Alle Konflikte aufgelöst.", fr: "Tous les conflits résolus.", es: "Todos los conflictos resueltos." }, + "Nothing to resolve.": { ru: "Нечего разрешать.", de: "Nichts aufzulösen.", fr: "Rien à résoudre.", es: "Nada que resolver." }, + "Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." }, + "{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" }, + "Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." }, + "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…" }, }; export function t(en: string, vars?: Record): string { diff --git a/src/p4.ts b/src/p4.ts index 147715b..329652a 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -85,7 +85,8 @@ export const p4 = { findSln: (scope: string) => invoke("find_sln", { scope }), buildSln: (path: string, config: string) => invoke("build_sln", { path, config }), launchFile: (path: string) => invoke("launch_file", { path }), - openInVscode: (depot: string) => invoke("open_in_vscode", { depot }), + listEditors: () => invoke("list_editors"), + openInEditor: (depot: string, editor: string) => invoke("open_in_editor", { depot, editor }), readDepot: async (depot: string): Promise => { const r: unknown = await invoke("read_depot", { depot }); if (r instanceof ArrayBuffer) return r; @@ -107,10 +108,35 @@ export const p4 = { groupMember: (group: string, user: string, add: boolean) => invoke("p4_group_member", { group, user, add }), readAiKey: () => invoke("read_ai_key"), saveAiKey: (key: string) => invoke("save_ai_key", { key }), + // run an arbitrary p4 command in the built-in terminal (combined stdout+stderr) + console: (args: string[]) => invoke("p4_console", { args }), + // tail the newest Unreal log for a project (.uproject path → Saved/Logs/*.log) + ueLog: (uproject: string, tailBytes = 200_000) => invoke("ue_log", { uproject, tailBytes }), + // --- collaboration / advanced Perforce --- + openedAll: (scope = "") => invoke("p4_opened_all", { scope }), + lock: (files: string[]) => invoke("p4_lock", { files }), + unlock: (files: string[]) => invoke("p4_unlock", { files }), + shelve: (change: string) => invoke("p4_shelve", { change }), + unshelve: (change: string) => invoke("p4_unshelve", { change }), + deleteShelf: (change: string) => invoke("p4_delete_shelf", { change }), + shelvedFiles: (change: string) => invoke("p4_shelved_files", { change }), + resolveList: () => invoke("p4_resolve_list"), + resolveFile: (file: string, mode: "am" | "ay" | "at") => invoke("p4_resolve_file", { file, mode }), + diff2: (spec1: string, spec2: string) => invoke("p4_diff2", { spec1, spec2 }), + annotate: (spec: string) => invoke("p4_annotate", { spec }), + searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }), + reopenTo: (change: string, files: string[]) => invoke("p4_reopen_to", { change, files }), + latestChange: (scope = "") => invoke("p4_latest_change", { scope }), }; export interface User { User?: string; Email?: string; FullName?: string; Access?: string; Update?: string; [k: string]: unknown } +// a detected code editor / IDE +export interface Editor { id: string; name: string; exe: string; args: string[] } +// which editor to open code files in (persisted; "" → auto-pick) +export function getEditor(): string { try { return localStorage.getItem("exd-editor") || ""; } catch { return ""; } } +export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} } + export interface Dir { dir?: string; [k: string]: unknown } // depot path -> short leaf name (last segment) export function leaf(path?: string): string { @@ -186,3 +212,13 @@ export function kindOf(name: string): Kind { if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image"; return "code"; } + +// true only for real text/source files — gates the "Summary" and "Open in IDE" +// buttons so they never appear on binaries (.pak/.dll/.exe/.bin/etc.) that +// happen to fall through kindOf() as "code". +const CODE_EXT = /\.(c|cc|cpp|cxx|h|hpp|hh|inl|cs|ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|kts|swift|m|mm|rb|php|lua|sh|bash|ps1|bat|cmd|sql|json|jsonc|yaml|yml|toml|ini|cfg|conf|xml|html|htm|css|scss|sass|less|md|markdown|txt|log|csv|gitignore|editorconfig|uproject|uplugin|build|target|usf|ush|hlsl|glsl|shader|cginc|props|targets|sln|vcxproj|csproj|gradle|cmake|make|mk)$/i; +export function isCodeFile(name: string): boolean { + const n = name.toLowerCase(); + if (/\.(uasset|umap|pak|exe|dll|so|dylib|bin|obj|lib|a|pdb|zip|7z|rar|gz|tar|png|jpe?g|gif|bmp|webp|svg|fbx|obj|gltf|glb|stl|ply|dae|wav|mp3|ogg|mp4|mov|ttf|otf)$/i.test(n)) return false; + return CODE_EXT.test(n); +}