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:
@ -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,
|
||||
|
||||
14
src/App.css
14
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)}
|
||||
|
||||
56
src/App.tsx
56
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
|
||||
</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>
|
||||
|
||||
@ -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…" },
|
||||
};
|
||||
|
||||
21
src/p4.ts
21
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<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 }[] {
|
||||
|
||||
Reference in New Issue
Block a user