Hardening + collaboration features from full app review

Security and robustness (backend):
- Strict CSP (was null); confine read_file/launch_file/ue_log to the client root
- p4 trust without -f: surface fingerprint CHANGES instead of auto-accepting (MITM)
- stdin=null by default + non-interactive P4EDITOR so terminal commands never hang
- 64 MB read caps on read_depot/read_file/print_depot (no OOM on huge .uasset/.pak)
- Recover from mutex poisoning instead of crashing every later p4 call
- Decode p4 output as UTF-8 then cp1251 (Cyrillic paths no longer corrupt)
- p4_changes never deletes changelists that hold shelved files
- Validate changelist number in p4_undo_change

Bug fixes (frontend):
- Focus-refresh preserves the checkbox selection (never re-checks deselected files)
- Staleness guards on refresh/describe/AI-summary/user-groups (no cross-selection races)
- Real F5/Ctrl+R/G/S/B handlers; match e.code so Ctrl+backquote works on RU layout
- Confirm dialog no longer fires on a stray global Enter
- Panel/dock/history drags end on window blur (no stuck resize)
- Commit draft clears on Submit only if unchanged since committing
- Summary/IDE buttons gated to real source files (not .pak/.dll/binaries)

New features:
- Exclusive lock/unlock plus held-by/locked-by indicators (p4 opened -a)
- Shelve/unshelve/delete-shelf on pending changelists
- Resolve UI (auto-merge / accept yours / accept theirs, per file or all) + banner
- Diff a history revision against the previous one; blame/annotate view
- Depot file search (Tools -> Search depot)
- Move opened files between pending changelists
- New-submit toasts from teammates + offline banner with auto-reconnect

Also: bake OpenRouter key at build time (git-ignored), drop AI settings UI,
IDE picker (detect installed editors), AI replies in the UI language,
persistent code summaries, metadata/CSP polish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 21:26:57 +03:00
parent 8e2cac4437
commit 6c16d4a4f4
12 changed files with 1487 additions and 163 deletions

View File

@ -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<Conn>);
/// 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<Vec<Value>, 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<Vec<Value>, 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<Vec<Value>, 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<Vec<Value>, 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<Value, String> {
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<Value, String> {
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<AppState>) -> Result<Conn, String> {
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<Vec<Value>, 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<Vec<Valu
#[tauri::command]
async fn p4_disconnect(state: State<'_, AppState>) -> 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<String, String> {
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<String, String> {
}
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<Vec<Value>
#[tauri::command]
async fn p4_undo_change(state: State<'_, AppState>, change: String) -> Result<String, String> {
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 <path>`).
/// `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<String>, // 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<String> {
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<String> {
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<String> {
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<Editor>, id: &str, name: &str, exe: Option<String>, 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<Editor> {
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<Editor> = 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<Vec<Editor>, 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<String, String> {
}
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<Value
}
/// Read raw bytes of a local file — used by the 3D model / texture viewer.
/// Confined to the workspace root and size-capped.
#[tauri::command]
async fn read_file(path: String) -> Result<tauri::ipc::Response, String> {
async fn read_file(state: State<'_, AppState>, path: String) -> Result<tauri::ipc::Response, String> {
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<tauri::
.next()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
.ok_or_else(|| "Could not resolve the file path".to_string())?;
check_local_size(&local)?;
std::fs::read(&local)
.map(tauri::ipc::Response::new)
.map_err(|e| format!("Could not read file: {e}"))
@ -928,24 +1167,251 @@ async fn read_depot(state: State<'_, AppState>, depot: String) -> Result<tauri::
#[tauri::command]
async fn print_depot(state: State<'_, AppState>, spec: String) -> Result<tauri::ipc::Response, String> {
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::<u64>().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<String>) -> Result<String, String> {
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 `<ProjectDir>/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<String, String> {
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<Vec<Value>, 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<String>) -> Result<String, String> {
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<String>) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<Value, String> {
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<Vec<Value>, 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<String, String> {
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<String, String> {
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<String, String> {
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<Vec<Value>, 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<String>) -> Result<String, String> {
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<Value, String> {
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<Value, String> {
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<std::path::PathBuf> {
let app = APP.get()?;
let dir = app.path().app_config_dir().ok()?;
@ -1056,6 +1531,10 @@ fn ai_key_path() -> Option<std::path::PathBuf> {
}
#[tauri::command]
async fn read_ai_key() -> Result<String, String> {
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,