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:
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||
|
||||
10
RELEASING.md
10
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
|
||||
|
||||
@ -2,9 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
<title>Exbyte Depot</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
10
src-tauri/Cargo.lock
generated
10
src-tauri/Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,10 +355,19 @@ 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)
|
||||
// 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 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);
|
||||
}
|
||||
// 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}"))?;
|
||||
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))?;
|
||||
}
|
||||
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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
100
src/App.css
100
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)}
|
||||
|
||||
740
src/App.tsx
740
src/App.tsx
File diff suppressed because it is too large
Load Diff
71
src/ai.ts
71
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<string, string> = { 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<string> {
|
||||
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<string> {
|
||||
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();
|
||||
|
||||
67
src/i18n.ts
67
src/i18n.ts
@ -75,6 +75,18 @@ const D: Record<string, Tr> = {
|
||||
"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<string, Tr> = {
|
||||
"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<string, Tr> = {
|
||||
"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, string | number>): string {
|
||||
|
||||
38
src/p4.ts
38
src/p4.ts
@ -85,7 +85,8 @@ export const p4 = {
|
||||
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
|
||||
buildSln: (path: string, config: string) => invoke<string>("build_sln", { path, config }),
|
||||
launchFile: (path: string) => invoke<void>("launch_file", { path }),
|
||||
openInVscode: (depot: string) => invoke<void>("open_in_vscode", { depot }),
|
||||
listEditors: () => invoke<Editor[]>("list_editors"),
|
||||
openInEditor: (depot: string, editor: string) => invoke<void>("open_in_editor", { depot, editor }),
|
||||
readDepot: async (depot: string): Promise<ArrayBuffer> => {
|
||||
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<string>("p4_group_member", { group, user, add }),
|
||||
readAiKey: () => invoke<string>("read_ai_key"),
|
||||
saveAiKey: (key: string) => invoke<void>("save_ai_key", { key }),
|
||||
// run an arbitrary p4 command in the built-in terminal (combined stdout+stderr)
|
||||
console: (args: string[]) => invoke<string>("p4_console", { args }),
|
||||
// tail the newest Unreal log for a project (.uproject path → Saved/Logs/*.log)
|
||||
ueLog: (uproject: string, tailBytes = 200_000) => invoke<string>("ue_log", { uproject, tailBytes }),
|
||||
// --- collaboration / advanced Perforce ---
|
||||
openedAll: (scope = "") => invoke<OpenedFile[]>("p4_opened_all", { scope }),
|
||||
lock: (files: string[]) => invoke<string>("p4_lock", { files }),
|
||||
unlock: (files: string[]) => invoke<string>("p4_unlock", { files }),
|
||||
shelve: (change: string) => invoke<string>("p4_shelve", { change }),
|
||||
unshelve: (change: string) => invoke<string>("p4_unshelve", { change }),
|
||||
deleteShelf: (change: string) => invoke<string>("p4_delete_shelf", { change }),
|
||||
shelvedFiles: (change: string) => invoke<Describe>("p4_shelved_files", { change }),
|
||||
resolveList: () => invoke<OpenedFile[]>("p4_resolve_list"),
|
||||
resolveFile: (file: string, mode: "am" | "ay" | "at") => invoke<string>("p4_resolve_file", { file, mode }),
|
||||
diff2: (spec1: string, spec2: string) => invoke<string>("p4_diff2", { spec1, spec2 }),
|
||||
annotate: (spec: string) => invoke<string>("p4_annotate", { spec }),
|
||||
searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }),
|
||||
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
|
||||
latestChange: (scope = "") => invoke<Change | null>("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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user