diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0c66e5..81c69dc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1867,6 +1867,19 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result, depot: String) -> Result { + let conn = current(&state)?; + if depot.trim().is_empty() { + return Err("No file".into()); + } + let v = run_json(&conn, &["filelog", "-l", "-m", "150", depot.trim()])?; + Ok(v.into_iter().next().unwrap_or(Value::Null)) +} + /// List jobs on the server (defect/task tracking). #[tauri::command] async fn p4_jobs(state: State<'_, AppState>) -> Result, String> { @@ -2209,6 +2222,7 @@ pub fn run() { p4_streams, p4_switch_stream, p4_stream_integ, + p4_filelog, p4_jobs, p4_fix, p4_job_spec, diff --git a/src/App.css b/src/App.css index c4e0031..d918265 100644 --- a/src/App.css +++ b/src/App.css @@ -796,6 +796,20 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .lk-who{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-size:11.5px;color:var(--muted);max-width:170px} .lk-client{font-size:10px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px} .tm-hint{padding:10px 16px;font-size:11.5px;line-height:1.5;color:var(--faint);border-bottom:1px solid var(--border-soft)} +/* file-history timeline */ +.flog-body{flex:1;overflow:auto;padding:6px 14px 14px} +.flog-row{display:flex;gap:12px;align-items:flex-start;padding:8px 6px} +.flog-row:hover{background:var(--hover);border-radius:8px} +.flog-rail{flex:0 0 14px;display:flex;flex-direction:column;align-items:center;align-self:stretch;position:relative} +.flog-dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;margin-top:4px;background:var(--accent-2);box-shadow:0 0 0 3px rgba(124,110,246,.15)} +.flog-dot.st-a{background:var(--add)}.flog-dot.st-d{background:var(--del)}.flog-dot.st-e{background:var(--edit)} +.flog-line{flex:1;width:2px;background:var(--border);margin-top:2px} +.flog-main{flex:1;min-width:0} +.flog-head{display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap} +.flog-head .rev{font-weight:700;color:var(--txt);font-variant-numeric:tabular-nums} +.flog-cl{color:var(--accent-2);font-variant-numeric:tabular-nums} +.flog-user{color:var(--muted)}.flog-time{color:var(--faint);margin-left:auto;font-variant-numeric:tabular-nums} +.flog-desc{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.5;white-space:pre-wrap;word-break:break-word} /* 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)} diff --git a/src/App.tsx b/src/App.tsx index 9780569..d05202f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ import ModelViewer from "./ModelViewer"; import CodeView from "./CodeView"; import UpdateBanner from "./Updater"; import "./App.css"; -import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor } from "./p4"; +import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev } from "./p4"; import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n"; import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai"; @@ -314,6 +314,7 @@ type ModalState = | { kind: "branch" } | { kind: "streams" } | { kind: "jobs" } + | { kind: "filelog"; depot: string; name: string } | { kind: "client"; name: string; isNew: boolean } | { kind: "blame"; spec: string; name: string } | { kind: "diff"; title: string; text: string } @@ -1274,7 +1275,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {tab === "changes" ? ( - + setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} /> ) : (
@@ -1291,7 +1292,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up); window.addEventListener("blur", up); // released outside the window → end drag }} /> - + setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} /> )}
)} @@ -1339,6 +1340,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {modal?.kind === "branch" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} {modal?.kind === "streams" && setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />} {modal?.kind === "jobs" && setModal(null)} onFlash={flash} />} + {modal?.kind === "filelog" && setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />} {modal?.kind === "client" && setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />} {modal?.kind === "blame" && setModal(null)} onFlash={flash} />} {modal?.kind === "diff" && setModal(null)} />} @@ -2021,6 +2023,51 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; ); } +/* ---------------- file history timeline (filelog) ---------------- */ +function FilelogModal({ depot, name, onClose, onFlash, onShowDiff }: { depot: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onShowDiff: (title: string, text: string) => void }) { + const [revs, setRevs] = useState([]); + const [busy, setBusy] = useState(true); + useEffect(() => { + let live = true; + p4.filelog(depot).then((v) => { if (live) { setRevs(filelogRevs(v)); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } }); + const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", k); + return () => { live = false; document.removeEventListener("keydown", k); }; + }, [depot]); // eslint-disable-line + async function diffPrev(rev: string) { + const n = Number(rev); + if (n <= 1) { onFlash(t("No earlier revision to compare."), true); return; } + try { const text = await p4.diff2(`${depot}#${n - 1}`, `${depot}#${n}`); onShowDiff(`${name} #${n - 1} ↔ #${n}`, text || t("(files are identical)")); } + catch (e) { onFlash(String(e), true); } + } + return ( +
+
e.stopPropagation()}> +
{I.log}

{t("File history")}

{name} + +
+
+ {busy &&
{t("Loading…")}
} + {!busy && revs.length === 0 &&
{t("No history.")}
} + {revs.map((r) => { + const st = statusOf(r.action); + return ( +
+ {Number(r.rev) > 1 && } +
+
#{r.rev}{st.ch}#{r.change}{r.user}{fmtTime(r.time)}
+
{(r.desc || "").trim() || t("(no description)")}
+
+ {Number(r.rev) > 1 && } +
+ ); + })} +
+
+
+ ); +} + /* ---------------- jobs (list / create / edit) ---------------- */ function JobsModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) { const [jobs, setJobs] = useState<{ Job?: string; Status?: string; Description?: string; User?: string }[]>([]); @@ -2565,7 +2612,7 @@ function FileList({ files, sel, selRows, checked, others, onRowClick, onToggle, } /* ---------------- preview panel ---------------- */ -function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; uproject?: string }) { +function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev, onFilelog, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; onFilelog?: (depot: string, name: string) => void; uproject?: string }) { const [explain, setExplain] = useState(null); // AI code summary const [explBusy, setExplBusy] = useState(false); const explSeq = useRef(0); // drop a slow AI response if the file changed meanwhile @@ -2633,6 +2680,7 @@ function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDif {hist && onDiffPrev && codeFile && ( onDiffPrev(file)}>{I.clock} )} + {onFilelog && dp && onFilelog(dp, name)}>{I.log}} {!hist && onRevert(file)}>{I.revert}{t("Revert")}} diff --git a/src/i18n.ts b/src/i18n.ts index 05c03f9..53e475c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -466,6 +466,9 @@ const D: Record = { "Attach job to #{n}": { ru: "Привязать задачу к #{n}", de: "Job an #{n} anhängen", fr: "Associer un job à #{n}", es: "Adjuntar job a #{n}" }, "Job ID": { ru: "ID задачи", de: "Job-ID", fr: "ID du job", es: "ID del job" }, "Job {j} linked to #{n}.": { ru: "Задача {j} привязана к #{n}.", de: "Job {j} mit #{n} verknüpft.", fr: "Job {j} associé à #{n}.", es: "Job {j} vinculado a #{n}." }, + "File history": { ru: "История файла", de: "Dateiverlauf", fr: "Historique du fichier", es: "Historial del archivo" }, + "No history.": { ru: "Истории нет.", de: "Kein Verlauf.", fr: "Aucun historique.", es: "Sin historial." }, + "Diff ↔ prev": { ru: "Diff ↔ пред.", de: "Diff ↔ vorher", fr: "Diff ↔ préc.", es: "Diff ↔ ant." }, "Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." }, "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…" }, }; diff --git a/src/p4.ts b/src/p4.ts index 01a95ff..9b30252 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -71,6 +71,7 @@ export const p4 = { streams: () => invoke<{ Stream?: string; Name?: string; Type?: string; Parent?: string; [k: string]: unknown }[]>("p4_streams"), switchStream: (stream: string) => invoke("p4_switch_stream", { stream }), streamInteg: (stream: string, direction: "down" | "up") => invoke("p4_stream_integ", { stream, direction }), + filelog: (depot: string) => invoke>("p4_filelog", { depot }), jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"), fix: (job: string, change: string) => invoke("p4_fix", { job, change }), jobSpec: (job: string) => invoke("p4_job_spec", { job }), @@ -179,6 +180,26 @@ export function leaf(path?: string): string { return p.slice(p.lastIndexOf("/") + 1); } +// per-file revision history parsed from p4 filelog's indexed fields +export interface FileRev { rev: string; change: string; action: string; user: string; time: string; desc: string; type: string } +export function filelogRevs(v: Record): FileRev[] { + const out: FileRev[] = []; + for (let i = 0; ; i++) { + const rev = v[`rev${i}`] as string | undefined; + if (rev == null) break; + out.push({ + rev, + change: (v[`change${i}`] as string) || "", + action: (v[`action${i}`] as string) || "", + user: (v[`user${i}`] as string) || "", + time: (v[`time${i}`] as string) || "", + desc: (v[`desc${i}`] as string) || "", + type: (v[`type${i}`] as string) || "", + }); + } + return out; +} + // a submitted changelist with indexed file fields (depotFile0, action0, ...) export interface Describe extends Change { [k: string]: unknown } export function describeFiles(d: Describe): { depotFile: string; action: string; rev: string }[] {