diff --git a/package.json b/package.json index 83d8990..d44dc5f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.2.1", + "version": "0.2.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f04f1c3..1533ea6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -951,7 +951,7 @@ dependencies = [ [[package]] name = "exbyte-depot" -version = "0.2.1" +version = "0.2.2" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c6f1347..c5c02cf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exbyte-depot" -version = "0.2.1" +version = "0.2.2" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38a4c47..fd8acbd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,7 @@ use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; use std::time::Instant; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; /// App handle stashed at startup so the low-level p4 helpers can emit a live /// command log to the frontend (a Perforce-style "Log" panel). @@ -683,13 +683,17 @@ async fn open_in_vscode(state: State<'_, AppState>, depot: String) -> Result<(), .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())?; + 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); } - c.arg("/C").arg(format!("code \"{local}\"")); + // 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(()) @@ -917,6 +921,26 @@ async fn read_depot(state: State<'_, AppState>, depot: String) -> Result, spec: String) -> Result { + let conn = current(&state)?; + 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(); + log_p4(&["print", "-q", &spec], 0, false, Some(&e)); + return Err(if e.is_empty() { "p4 print failed".into() } else { e }); + } + log_p4(&["print", "-q", &spec], out.stdout.len() as i64, true, None); + Ok(tauri::ipc::Response::new(out.stdout)) +} + /// Switch the active workspace (client) without re-authenticating. #[tauri::command] async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result { @@ -939,6 +963,113 @@ async fn p4_depots(state: State<'_, AppState>) -> Result, String> { run_json(&conn, &["depots"]) } +/// All Perforce users (with last-access time → activity indicator). +#[tauri::command] +async fn p4_users(state: State<'_, AppState>) -> Result, String> { + let conn = current(&state)?; + run_json(&conn, &["users"]) +} + +/// All groups on the server, or the groups a given user belongs to. +#[tauri::command] +async fn p4_groups(state: State<'_, AppState>, user: String) -> Result, String> { + let conn = current(&state)?; + if user.is_empty() { + run_json(&conn, &["groups"]) + } else { + run_json(&conn, &["groups", "-u", &user]) + } +} + +/// Add or remove a user from a group (role assignment). Requires admin/super. +/// Edits the group spec's Users list and pipes it back via `group -i`. +#[tauri::command] +async fn p4_group_member( + state: State<'_, AppState>, + group: String, + user: String, + add: bool, +) -> Result { + let conn = current(&state)?; + let spec = run_text(&conn, &["group", "-o", &group])?; + // collect existing Users: entries (indented under the "Users:" field) + let mut users: Vec = Vec::new(); + let mut in_users = false; + let mut head = String::new(); + for line in spec.lines() { + if line.starts_with("Users:") { + in_users = true; + continue; + } + if in_users { + if line.starts_with('\t') || line.starts_with(' ') { + let u = line.trim().to_string(); + if !u.is_empty() { + users.push(u); + } + continue; + } else if line.trim().is_empty() { + continue; + } else { + in_users = false; // next field — stop collecting + } + } + if !in_users { + head.push_str(line); + head.push('\n'); + } + } + users.retain(|u| !u.eq_ignore_ascii_case(&user)); + if add { + users.push(user.clone()); + } + // rebuild spec: head (everything except Users:) + fresh Users section + let mut out = head.trim_end().to_string(); + out.push_str("\n\nUsers:\n"); + for u in &users { + out.push('\t'); + out.push_str(u); + out.push('\n'); + } + let mut child = p4_cmd(&conn, false) + .args(["group", "-i"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start p4: {e}"))?; + if let Some(si) = child.stdin.as_mut() { + si.write_all(out.as_bytes()).map_err(|e| e.to_string())?; + } + 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()); + } + Ok(String::from_utf8_lossy(&res.stdout).trim().to_string()) +} + +/// Path to the (git-ignored) OpenRouter API key, stored in the app config dir. +fn ai_key_path() -> Option { + let app = APP.get()?; + let dir = app.path().app_config_dir().ok()?; + Some(dir.join("openrouter.key")) +} +#[tauri::command] +async fn read_ai_key() -> Result { + Ok(ai_key_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default()) +} +#[tauri::command] +async fn save_ai_key(key: String) -> Result<(), String> { + let p = ai_key_path().ok_or("No config dir")?; + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + std::fs::write(&p, key.trim()).map_err(|e| e.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default() @@ -989,8 +1120,14 @@ pub fn run() { p4_describe, read_file, read_depot, + print_depot, p4_switch_client, p4_depots, + p4_users, + p4_groups, + p4_group_member, + read_ai_key, + save_ai_key, p4_dirs, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3c88e86..7aebfc0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "exbyte-depot", - "version": "0.2.1", + "version": "0.2.2", "identifier": "com.bonchellon.exbyte-depot", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 17c6a21..1e40bfa 100644 --- a/src/App.css +++ b/src/App.css @@ -32,7 +32,9 @@ body{ } button{font-family:var(--font)} -.win{height:100vh;width:100%;display:flex;flex-direction:column; +/* height divided by the zoom factor: CSS `zoom` shrinks the box but `vh` still + measures the unzoomed viewport, so without this a gap grows below at zoom<1 */ +.win{height:calc(100vh / var(--zoom,1));width:100%;display:flex;flex-direction:column; background:linear-gradient(180deg,var(--win-a),var(--win-b));overflow:hidden} /* titlebar + inline menu */ @@ -556,6 +558,23 @@ body.resizing{cursor:col-resize!important;user-select:none} /* virtualized changelist-file row (History detail) */ .crow{position:absolute;left:0;right:0;height:52px;display:flex;gap:12px;align-items:center;padding:0 16px;border-bottom:1px solid var(--border-soft)} .crow:hover{background:var(--hover)} +.crow.click{cursor:pointer} +.crow.click:hover .crow-go{opacity:1;transform:translateX(0)} +.crow.sel{background:rgba(124,110,246,.12);box-shadow:inset 2px 0 0 var(--accent)} +.crow-go{margin-left:auto;flex:0 0 auto;color:var(--faint);font-size:18px;line-height:1;opacity:0;transform:translateX(-4px);transition:opacity .12s,transform .12s;display:flex;align-items:center} +.crow-go svg{width:16px;height:16px} +.gbtn.back{padding:6px 8px}.gbtn.back svg{width:16px;height:16px} +/* 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 */ +/* 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} +.histsplit .histlist .crow-go{opacity:0} +.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)} .hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px} .hbody{display:flex;flex-direction:column;gap:3px;min-width:0} .hdesc{font-size:13px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical} @@ -638,3 +657,68 @@ body.resizing{cursor:col-resize!important;user-select:none} animation:toastin .25s ease;white-space:pre-wrap;text-align:center} .toast.err{border-color:rgba(242,99,126,.4);color:var(--del)} @keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}} + +/* ---------------- avatars ---------------- */ +.avatar{position:relative;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;letter-spacing:.02em;text-transform:uppercase;box-shadow:inset 0 0 0 1px rgba(255,255,255,.14);user-select:none} +.adot{position:absolute;right:-1px;bottom:-1px;width:9px;height:9px;border-radius:50%;border:2px solid var(--elevated);background:var(--faint)} +.adot.active{background:#3ecb8f}.adot.recent{background:#f4c04e}.adot.idle{background:#6b6b7a} + +/* ---------------- AI commit summary ---------------- */ +.summrow{display:flex;gap:8px;align-items:stretch} +.aibtn{flex:0 0 auto;display:flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:9px;padding:0 12px; + font-size:12px;font-weight:600;font-family:var(--font);cursor:pointer;color:var(--accent-2); + background:linear-gradient(135deg,rgba(124,110,246,.16),rgba(124,110,246,.06));transition:.15s} +.aibtn:hover:not(:disabled){filter:brightness(1.12);border-color:rgba(124,110,246,.5)} +.aibtn:disabled{opacity:.5;cursor:default} +.aibtn svg{width:15px;height:15px} +.aibtn.busy{color:var(--faint)} +.aibtn.icon{padding:0;width:34px;justify-content:center} + +/* AI settings block inside Settings modal */ +.ai-set{margin-top:16px;padding-top:14px;border-top:1px solid var(--border-soft)} +.ai-set-h{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;color:var(--txt);margin-bottom:6px} +.ai-set-h svg{width:16px;height:16px;color:var(--accent-2)} +.keyfield{display:flex;align-items:center;gap:6px;flex:1;max-width:230px} +.kf-in{flex:1;min-width:0;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:6px 9px;font-size:12px;color:var(--txt);outline:none;font-family:var(--font)} +.kf-in.mono{font-family:var(--mono);font-size:11.5px} +.kf-in:focus{border-color:var(--accent)} +.kf-eye{flex:0 0 auto;width:28px;height:28px;border:1px solid var(--border);background:var(--panel);border-radius:8px;cursor:pointer;font-size:13px;line-height:1} +.ai-hint{font-size:11px;color:var(--faint);line-height:1.5;margin-top:8px} + +/* ---------------- People & Roles ---------------- */ +.picker.wide{width:720px;max-width:94%;height:560px;max-height:82vh;display:flex;flex-direction:column} +.ph-sub{margin-left:8px;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums} +.picker.wide .ph-sub{margin-right:auto} +.ur-body{flex:1;display:flex;min-height:0} +.ur-list{flex:0 0 300px;display:flex;flex-direction:column;border-right:1px solid var(--border-soft);min-height:0} +.ur-filter{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border-soft)} +.ur-filter svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto} +.ur-filter input{flex:1;background:none;border:none;outline:none;color:var(--txt);font-size:12.5px;font-family:var(--font)} +.ur-scroll{flex:1;overflow-y:auto;padding:6px} +.ur-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;cursor:pointer;transition:background .12s} +.ur-row:hover{background:var(--hover)} +.ur-row.on{background:rgba(124,110,246,.13);box-shadow:inset 0 0 0 1px rgba(124,110,246,.28)} +.ur-meta{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1} +.ur-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:6px} +.ur-you{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--accent-2);background:rgba(124,110,246,.16);border-radius:6px;padding:1px 6px} +.ur-sub{font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ur-actd{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--faint)} +.ur-actd.active{background:#3ecb8f;box-shadow:0 0 0 3px rgba(62,203,143,.18)} +.ur-actd.recent{background:#f4c04e}.ur-actd.idle{background:#4b4b58} +.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center} +.ur-detail{flex:1;display:flex;flex-direction:column;min-width:0;overflow-y:auto} +.ur-dhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--border-soft)} +.ur-dinfo{min-width:0} +.ur-dname{font-size:16px;font-weight:700} +.ur-dsub{font-size:12px;color:var(--faint);margin-top:2px} +.ur-grouphd{display:flex;align-items:center;gap:8px;padding:14px 20px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)} +.ur-groups{display:flex;flex-wrap:wrap;gap:8px;padding:0 20px} +.ur-chip{display:flex;align-items:center;gap:7px;border:1px solid var(--border);background:var(--panel);color:var(--muted); + border-radius:9px;padding:7px 11px;font-size:12.5px;font-weight:500;font-family:var(--font);cursor:pointer;transition:.14s} +.ur-chip:hover:not(:disabled){border-color:var(--accent);color:var(--txt)} +.ur-chip:disabled{opacity:.6;cursor:default} +.ur-chip.on{border-color:rgba(124,110,246,.5);background:rgba(124,110,246,.13);color:var(--txt)} +.urc-box{width:15px;height:15px;border-radius:5px;border:1.5px solid var(--border);display:grid;place-items:center;flex:0 0 auto;background:var(--panel)} +.ur-chip.on .urc-box{background:var(--accent);border-color:var(--accent);color:#fff} +.urc-box svg{width:11px;height:11px} +.ur-detail .ai-hint{padding:12px 20px 20px} diff --git a/src/App.tsx b/src/App.tsx index c749d9b..23856ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,8 +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, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4"; +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 { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n"; +import { aiSummary, initials, avatarColor, activity, getModel, setModel, getKeyLocal, setKeyLocal } from "./ai"; /* ---------------- window controls (frameless) ---------------- */ function WinControls() { @@ -37,6 +38,10 @@ const I = { spinner: , cube: , revert: , + back: , + chevron: , + spark: , + people: , clock: , log: , vscode: , @@ -69,6 +74,10 @@ function useZoom(): [number, (z: number) => void] { }); useEffect(() => { (document.documentElement.style as CSSStyleDeclaration & { zoom: string }).zoom = String(zoom); + // CSS `zoom` scales layout but leaves `100vh` measuring the *unzoomed* viewport, + // so at zoom<1 a gap grows at the bottom. Expose the factor so the root can + // compensate its height (see `.win` in App.css). + document.documentElement.style.setProperty("--zoom", String(zoom)); try { localStorage.setItem("exd-zoom", String(zoom)); } catch {} }, [zoom]); return [zoom, (z) => setZoom(Math.min(2, Math.max(0.5, Math.round(z * 100) / 100)))]; @@ -262,6 +271,7 @@ type ModalState = | { kind: "workspaces"; items: Client[]; current: string } | { kind: "scope"; path: string; dirs: Dir[] } | { kind: "settings" } + | { kind: "users" } | { kind: "about" }; type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null }; @@ -280,8 +290,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [expandedCL, setExpandedCL] = useState>(new Set()); // expanded pending changelist cards const [prompt, setPrompt] = useState void }>(null); const [filter, setFilter] = useState(""); - const [desc, setDesc] = useState(""); // commit summary (first line) - const [descBody, setDescBody] = useState(""); // extended description (optional) + const [desc, setDesc] = useState(() => { try { return localStorage.getItem("exd-draft-summary") || ""; } catch { return ""; } }); // commit summary (first line) — persisted draft + const [descBody, setDescBody] = useState(() => { try { return localStorage.getItem("exd-draft-body") || ""; } catch { return ""; } }); // extended description (optional) — persisted draft + // keep the typed commit message as a draft so a refresh/restart never loses it; it is cleared only on Submit + useEffect(() => { try { localStorage.setItem("exd-draft-summary", desc); } catch {} }, [desc]); + useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]); const [busy, setBusy] = useState(false); const [scanning, setScanning] = useState(false); // background reconcile in progress const [openMenu, setOpenMenu] = useState(null); @@ -290,6 +303,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [detail, setDetail] = useState(null); const [selChange, setSelChange] = useState(""); // highlighted History row (instant) const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading + const [histFile, setHistFile] = useState(null); // drilled-into submitted file (read-only preview) const [dragging, setDragging] = useState(false); const [ask, setAsk] = useState void }>(null); const [pending, setPending] = useState([]); // committed-but-not-pushed changelists @@ -303,6 +317,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [buildOpen, setBuildOpen] = useState(false); const [buildMin, setBuildMin] = useState(false); // collapsed to a small floating chip const [buildPick, setBuildPick] = useState(false); // configuration picker before building + const [aiBusy, setAiBusy] = useState(false); // AI commit-summary generation in flight const [ctx, setCtx] = useState(null); // right-click menu const [logs, setLogs] = useState([]); // live p4 command log (P4V-style) const [logOpen, setLogOpen] = useState(false); @@ -314,14 +329,17 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set // resizable panels (persisted): left panel width + Get-Latest zone width const [leftW, setLeftW] = useState(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; }); const [syncW, setSyncW] = useState(() => { const v = Number(localStorage.getItem("exd-sync-w")); return v >= 150 ? v : 220; }); + const [histW, setHistW] = useState(() => { const v = Number(localStorage.getItem("exd-hist-w")); return v >= 240 ? v : 340; }); // History file-list column width useEffect(() => { try { localStorage.setItem("exd-left-w", String(Math.round(leftW))); } catch {} }, [leftW]); useEffect(() => { try { localStorage.setItem("exd-sync-w", String(Math.round(syncW))); } catch {} }, [syncW]); + useEffect(() => { try { localStorage.setItem("exd-hist-w", String(Math.round(histW))); } catch {} }, [histW]); - function startResize(e: ReactMouseEvent, kind: "left" | "sync") { + function startResize(e: ReactMouseEvent, kind: "left" | "sync" | "hist") { e.preventDefault(); - const startX = e.clientX, startLeft = leftW, startSync = syncW; + const startX = e.clientX, startLeft = leftW, startSync = syncW, startHist = histW; const move = (ev: MouseEvent) => { if (kind === "left") setLeftW(Math.max(300, Math.min(startLeft + (ev.clientX - startX), window.innerWidth - 420))); + else if (kind === "hist") setHistW(Math.max(240, Math.min(startHist + (ev.clientX - startX), 620))); else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520))); }; const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.classList.remove("resizing"); }; @@ -466,6 +484,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set async function openChange(c: Change) { const n = c.change || ""; setSelChange(n); // highlight the row immediately — no waiting for the load + setHistFile(null); // leave any drilled-in file view setDetail(null); setDetailBusy(true); try { setDetail(await p4.describe(n)); } @@ -513,6 +532,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set } function switchTab(t: Tab) { setTab(t); + setHistFile(null); if (t === "history" && history.length === 0) loadHistory(); } @@ -585,11 +605,27 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set try { const cl = await p4.commit(message, list); flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl })); - setDesc(""); setDescBody(""); + // keep the draft — a local commit is not final; it clears only on Submit (see pushAll / submitOne) await refresh(); } catch (e) { flash(String(e), true); setBusy(false); } } + // Ask the AI (OpenRouter) to draft a commit summary + description from the checked files. + async function genAiSummary() { + const picked = files.filter((f) => (f.change || "default") === "default" && checked.has(f.depotFile || "")); + if (!picked.length) { flash(t("Select some files first."), true); return; } + setAiBusy(true); + try { + const { summary, description } = await aiSummary(picked); + if (summary) setDesc(summary); + if (description) setDescBody(description); + flash(t("AI drafted a commit message — review & edit before committing.")); + } catch (e) { + const msg = String(e); + flash(msg.includes("No OpenRouter API key") ? t("Set an OpenRouter API key in Settings → AI first.") : msg, true); + } finally { setAiBusy(false); } + } + // Submit / push: send all committed (pending) changelists to the server. (like git push) async function pushAll() { if (!pending.length) return; @@ -602,7 +638,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); } } if (errors.length) flash(errors.join(" · "), true); - else flash(t("Sent to the server: {n} changelist(s).", { n })); + else { flash(t("Sent to the server: {n} changelist(s).", { n })); setDesc(""); setDescBody(""); } // draft done → clear on successful Submit await refresh(); await loadHistory(); // the submit created a new submitted changelist — refresh History } @@ -611,7 +647,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set async function submitOne(change: string) { if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return; setBusy(true); - try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); await refresh(); await loadHistory(); } + try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); setDesc(""); setDescBody(""); await refresh(); await loadHistory(); } // draft clears on successful Submit catch (e) { flash(String(e), true); setBusy(false); } } // Uncommit: move a changelist's files back into the working set (default) @@ -723,7 +759,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }], Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }], Window: [{ label: (logOpen ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => setLogOpen((o) => !o) }], - Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: startBuild }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }], + Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: startBuild }, { label: t("People & Roles…"), act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }], Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }], }; @@ -746,6 +782,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set ))}
{info?.serverAddress || "connected"}
+ @@ -871,8 +908,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set )}
- setDesc(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} /> +
+ setDesc(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} /> + +