File history timeline (filelog) with diff-to-previous

- File history button in the preview header opens a per-file revision timeline
  (rev, changelist, action, user, date, description) with Diff to previous
- Backend p4 filelog -l; filelogRevs parser

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 12:03:32 +03:00
parent 2069f0fed4
commit ecc5b8e9bf
5 changed files with 104 additions and 4 deletions

View File

@ -1867,6 +1867,19 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result<V
Ok(v.into_iter().next().unwrap_or(Value::Null))
}
/// Full revision history of a single file (`p4 filelog -l`), including
/// per-revision changelist, action, user, time and description. Returns the
/// tagged object with rev0/change0/action0/… arrays.
#[tauri::command]
async fn p4_filelog(state: State<'_, AppState>, depot: String) -> Result<Value, String> {
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<Vec<Value>, 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,

View File

@ -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)}

View File

@ -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
</div>
{tab === "changes" ? (
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
) : (
<div className={"histsplit" + (histFile ? "" : " solo")}>
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
@ -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
}} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
</>)}
</div>
)}
@ -1339,6 +1340,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
{modal?.kind === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => 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<FileRev[]>([]);
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 (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.log}<h3>{t("File history")}</h3><span className="ph-sub">{name}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="flog-body">
{busy && <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>}
{!busy && revs.length === 0 && <div className="ur-empty">{t("No history.")}</div>}
{revs.map((r) => {
const st = statusOf(r.action);
return (
<div key={r.rev} className="flog-row">
<span className="flog-rail"><span className={"flog-dot " + st.cls} />{Number(r.rev) > 1 && <span className="flog-line" />}</span>
<div className="flog-main">
<div className="flog-head"><span className="rev">#{r.rev}</span><span className={"stat " + st.cls}>{st.ch}</span><span className="flog-cl">#{r.change}</span><span className="flog-user">{r.user}</span><span className="flog-time">{fmtTime(r.time)}</span></div>
<div className="flog-desc">{(r.desc || "").trim() || t("(no description)")}</div>
</div>
{Number(r.rev) > 1 && <button className="rslv-act" onClick={() => diffPrev(r.rev)}>{t("Diff ↔ prev")}</button>}
</div>
);
})}
</div>
</div>
</div>
);
}
/* ---------------- 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<string | null>(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 && (
<span className="gbtn icon" title={t("Diff against the previous revision")} onClick={() => onDiffPrev(file)}>{I.clock}</span>
)}
{onFilelog && dp && <span className="gbtn icon" title={t("File history")} onClick={() => onFilelog(dp, name)}>{I.log}</span>}
{!hist && <span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>}
</span>
</div>

View File

@ -466,6 +466,9 @@ const D: Record<string, Tr> = {
"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…" },
};

View File

@ -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<P4Info>("p4_switch_stream", { stream }),
streamInteg: (stream: string, direction: "down" | "up") => invoke<string>("p4_stream_integ", { stream, direction }),
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"),
fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }),
jobSpec: (job: string) => invoke<string>("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<string, unknown>): 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 }[] {