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