Versionierung der Arbeitsbereiche: Verlauf je Datei mit Agent, Lauf und Vorfassung (#719)
Eigene inhaltsadressierte Ablage (Agents/.history/objects, SHA-256) statt Git, Journal FileHistory in state.db. Jeder Schreibweg - FileRW write/append/copy/ delete/stock_add, FTP- und RocketChat-Downloads - legt die alte Fassung ab und verbucht die Aenderung mit RunId. Externe Aenderungen findet ein Abgleich beim Start und stuendlich. Aufbewahrung nach Tagen und Fassungen, verwaiste Objekte werden entfernt. WebUI: Dateiverlauf mit Filter, Diff und Wiederherstellen nur durch den Menschen mit Grund. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
621a83de43
commit
e9c39c3c96
@@ -869,6 +869,103 @@ button.klein {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* #719: the file history */
|
||||
.dateiverlauf-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dateiverlauf-filter input,
|
||||
.dateiverlauf-filter select {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dateiverlauf-liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dateiverlauf-pfad {
|
||||
font-family: var(--mono, monospace);
|
||||
word-break: break-all;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dateiverlauf-aktion {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--divider);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dateiverlauf-aktion.aktion-delete,
|
||||
.dateiverlauf-aktion.aktion-external {
|
||||
border-color: var(--gefahr);
|
||||
color: var(--gefahr);
|
||||
}
|
||||
|
||||
.dateiverlauf-aktion.aktion-restore {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.verknuepfung {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.verknuepfung:hover,
|
||||
.verknuepfung:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.modal-box.modal-breit {
|
||||
max-width: min(1100px, 100%);
|
||||
}
|
||||
|
||||
.dateiverlauf-diff,
|
||||
.dateiverlauf-text {
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--divider);
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dateiverlauf-seiten {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* One block per line, so the colour covers the whole line, not just its text. */
|
||||
.dateiverlauf-diff span {
|
||||
display: block;
|
||||
min-height: 1.3em;
|
||||
}
|
||||
|
||||
.diff-removed {
|
||||
background: color-mix(in srgb, var(--gefahr) 16%, transparent);
|
||||
}
|
||||
|
||||
.diff-added {
|
||||
background: color-mix(in srgb, var(--erfolg, #2e7d32) 18%, transparent);
|
||||
}
|
||||
|
||||
/* #720: files with repeated write conflicts, above the team canvas */
|
||||
.team-konflikte {
|
||||
border: 1px solid var(--divider);
|
||||
|
||||
@@ -35,6 +35,7 @@ import { renderUsageRuns } from "./views/usage-runs.js";
|
||||
import { renderUsageTimeline } from "./views/usage-timeline.js";
|
||||
import { renderRunDetail } from "./views/run-detail.js";
|
||||
import { renderPromptHistory } from "./views/prompt-history.js";
|
||||
import { renderFileHistory } from "./views/file-history.js";
|
||||
import { renderTeam, leaveTeam } from "./views/team.js";
|
||||
import { renderOrganisation } from "./views/organisation.js";
|
||||
import { renderCases, holeMenschenfaelle } from "./views/cases.js";
|
||||
@@ -65,6 +66,7 @@ registriereRoute("/verbrauch/runs", () => { verlasseAnsicht(); return renderUsag
|
||||
registriereRoute("/verbrauch/runs/:runId", (p) => { verlasseAnsicht(); return renderRunDetail(p.runId, "/verbrauch/runs"); });
|
||||
registriereRoute("/verbrauch/timeline", () => { verlasseAnsicht(); return renderUsageTimeline(); });
|
||||
registriereRoute("/prompt-history", () => { verlasseAnsicht(); return renderPromptHistory(); });
|
||||
registriereRoute("/file-history", () => { verlasseAnsicht(); return renderFileHistory(); });
|
||||
registriereRoute("/prompt-history/:runId", (p) => { verlasseAnsicht(); return renderRunDetail(p.runId, "/prompt-history"); });
|
||||
registriereRoute("/agenten-chats", () => { verlasseAnsicht(); return rendereAgentenChats(); });
|
||||
registriereRoute("/protokoll", () => { verlasseAnsicht(); return rendereProtokoll(); });
|
||||
|
||||
@@ -986,5 +986,49 @@
|
||||
|
||||
"team.conflicts.title": "Dateien, in denen sich Agenten gegenseitig überschreiben",
|
||||
"team.conflicts.hint": "Hier wurden Schreibvorgänge abgelehnt, weil sie die Änderung eines anderen Agenten verloren hätten. Passiert das oft, sollte die Ablage anders werden: eine Datei je Agent oder Tag, Anhängen statt Ersetzen oder eine Tabelle.",
|
||||
"team.conflicts.count": "{n} Konflikte in 7 Tagen, zwischen {agents}; zuletzt {time}"
|
||||
"team.conflicts.count": "{n} Konflikte in 7 Tagen, zwischen {agents}; zuletzt {time}",
|
||||
|
||||
"nav.fileHistory": "Dateiverlauf",
|
||||
"fileHistory.title": "Dateiverlauf",
|
||||
"fileHistory.intro": "Jede Änderung in den Arbeitsbereichen: wer welche Datei in welchem Lauf angelegt, ersetzt, ergänzt oder gelöscht hat, und was vorher darin stand. Änderungen außerhalb der Werkzeuge werden gefunden und als extern geführt. Eine Fassung lässt sich zurückholen – mit Grund, als eigener Eintrag.",
|
||||
"fileHistory.workspace": "Arbeitsbereich",
|
||||
"fileHistory.allWorkspaces": "Alle Arbeitsbereiche",
|
||||
"fileHistory.agent": "Agent",
|
||||
"fileHistory.path": "Dateipfad (genau)",
|
||||
"fileHistory.run": "Lauf",
|
||||
"fileHistory.apply": "Filtern",
|
||||
"fileHistory.reset": "Zurücksetzen",
|
||||
"fileHistory.more": "Ältere Einträge",
|
||||
"fileHistory.unavailable": "Dateiverlauf nicht verfügbar",
|
||||
"fileHistory.empty.title": "Keine Änderungen",
|
||||
"fileHistory.empty.text": "Nichts passt – oder seit Beginn des Verlaufs wurde nichts geändert.",
|
||||
"fileHistory.external": "außerhalb der Werkzeuge",
|
||||
"fileHistory.toRun": "Lauf",
|
||||
"fileHistory.onlyThisFile": "Nur diese Datei zeigen",
|
||||
"fileHistory.show": "Anzeigen",
|
||||
"fileHistory.action.create": "angelegt",
|
||||
"fileHistory.action.replace": "ersetzt",
|
||||
"fileHistory.action.append": "ergänzt",
|
||||
"fileHistory.action.copy": "kopiert",
|
||||
"fileHistory.action.delete": "gelöscht",
|
||||
"fileHistory.action.restore": "wiederhergestellt",
|
||||
"fileHistory.action.external": "extern",
|
||||
"fileHistory.before": "Vorher",
|
||||
"fileHistory.after": "Nachher",
|
||||
"fileHistory.text": "Text",
|
||||
"fileHistory.absent": "keine Datei",
|
||||
"fileHistory.notKept": "nicht aufbewahrt",
|
||||
"fileHistory.tooLarge": "zu groß zum Anzeigen ({bytes} Bytes)",
|
||||
"fileHistory.binary": "binär ({bytes} Bytes)",
|
||||
"fileHistory.restoreBefore": "Fassung davor wiederherstellen",
|
||||
"fileHistory.restoreAfter": "Fassung danach wiederherstellen",
|
||||
"fileHistory.restore": "Wiederherstellen",
|
||||
"fileHistory.restoreTitle": "{path} wiederherstellen",
|
||||
"fileHistory.restoreBeforeHint": "Die Datei bekommt den Inhalt, den sie vor dieser Änderung hatte. Was jetzt darin steht, bleibt im Verlauf erhalten.",
|
||||
"fileHistory.restoreAfterHint": "Die Datei bekommt den Inhalt, den ihr diese Änderung gegeben hat. Was jetzt darin steht, bleibt im Verlauf erhalten.",
|
||||
"fileHistory.reason": "Grund",
|
||||
"fileHistory.reasonPlaceholder": "Warum wird diese Fassung zurückgeholt?",
|
||||
"fileHistory.reasonMissing": "Eine Wiederherstellung braucht einen Grund.",
|
||||
"fileHistory.restored": "{path} wiederhergestellt.",
|
||||
"fileHistory.failed": "Fehlgeschlagen: {error}"
|
||||
}
|
||||
|
||||
@@ -986,5 +986,49 @@
|
||||
|
||||
"team.conflicts.title": "Files agents keep overwriting each other in",
|
||||
"team.conflicts.hint": "Writes were refused here because they would have lost another agent's change. If it keeps happening, change how the file is kept: one file per agent or day, appending instead of replacing, or a table.",
|
||||
"team.conflicts.count": "{n} conflicts in 7 days, between {agents}; last {time}"
|
||||
"team.conflicts.count": "{n} conflicts in 7 days, between {agents}; last {time}",
|
||||
|
||||
"nav.fileHistory": "File History",
|
||||
"fileHistory.title": "File History",
|
||||
"fileHistory.intro": "Every change in the workspaces: who created, replaced, appended to or deleted which file, in which run, and what was in it before. Changes made outside the tools are found and listed as external. A version can be put back — with a reason, as an entry of its own.",
|
||||
"fileHistory.workspace": "Workspace",
|
||||
"fileHistory.allWorkspaces": "All workspaces",
|
||||
"fileHistory.agent": "Agent",
|
||||
"fileHistory.path": "File path (exact)",
|
||||
"fileHistory.run": "Run",
|
||||
"fileHistory.apply": "Filter",
|
||||
"fileHistory.reset": "Reset",
|
||||
"fileHistory.more": "Older entries",
|
||||
"fileHistory.unavailable": "File history unavailable",
|
||||
"fileHistory.empty.title": "No changes",
|
||||
"fileHistory.empty.text": "Nothing matches — or nothing has been changed since the history started.",
|
||||
"fileHistory.external": "outside the tools",
|
||||
"fileHistory.toRun": "run",
|
||||
"fileHistory.onlyThisFile": "Show only this file",
|
||||
"fileHistory.show": "Show",
|
||||
"fileHistory.action.create": "created",
|
||||
"fileHistory.action.replace": "replaced",
|
||||
"fileHistory.action.append": "appended",
|
||||
"fileHistory.action.copy": "copied",
|
||||
"fileHistory.action.delete": "deleted",
|
||||
"fileHistory.action.restore": "restored",
|
||||
"fileHistory.action.external": "external",
|
||||
"fileHistory.before": "Before",
|
||||
"fileHistory.after": "After",
|
||||
"fileHistory.text": "text",
|
||||
"fileHistory.absent": "no file",
|
||||
"fileHistory.notKept": "not kept",
|
||||
"fileHistory.tooLarge": "too large to show ({bytes} bytes)",
|
||||
"fileHistory.binary": "binary ({bytes} bytes)",
|
||||
"fileHistory.restoreBefore": "Restore version before",
|
||||
"fileHistory.restoreAfter": "Restore version after",
|
||||
"fileHistory.restore": "Restore",
|
||||
"fileHistory.restoreTitle": "Restore {path}",
|
||||
"fileHistory.restoreBeforeHint": "The file gets the content it had before this change. What it holds now is kept in the history.",
|
||||
"fileHistory.restoreAfterHint": "The file gets the content this change gave it. What it holds now is kept in the history.",
|
||||
"fileHistory.reason": "Reason",
|
||||
"fileHistory.reasonPlaceholder": "Why is this version put back?",
|
||||
"fileHistory.reasonMissing": "A restore needs a reason.",
|
||||
"fileHistory.restored": "{path} restored.",
|
||||
"fileHistory.failed": "Failed: {error}"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,12 @@
|
||||
</svg>
|
||||
<span class="nav-label" data-i18n="nav.promptHistory">Prompt History</span>
|
||||
</a>
|
||||
<a href="#/file-history" class="nav-link" data-i18n-title="nav.fileHistory">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z M14 3v5h5 M12 11v4l2 1.5"/>
|
||||
</svg>
|
||||
<span class="nav-label" data-i18n="nav.fileHistory">File History</span>
|
||||
</a>
|
||||
<a href="#/agenten-chats" class="nav-link" title="Agent Chats" data-i18n-title="nav.agentChats">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M8 12h.01M12 12h.01M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z"/>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// ClawdDotNet — Analysis › File History (#719)
|
||||
//
|
||||
// Who created, replaced, appended to or deleted which file in the workspaces, in which run,
|
||||
// and what was in it before. Every entry links to its run. A version can be put back — by a
|
||||
// human only, with a reason, as an entry of its own.
|
||||
//
|
||||
// File contents are foreign text: only ever inserted via textContent.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { erstelleElement, erstelleKarte, erstelleModal, formatiereDatumZeit, zeigeMeldung } from "../modules/dom.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
/** Diff only up to this many lines per side — beyond, both versions are shown as they are. */
|
||||
const MAX_DIFF_LINES = 3000;
|
||||
|
||||
export async function renderFileHistory() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
|
||||
const filter = { workspace: "", path: "", agent: "", run: "" };
|
||||
const list = erstelleElement("div", { klasse: "dateiverlauf-liste" });
|
||||
const more = erstelleElement("button", { type: "button", klasse: "schaltflaeche flach", text: t("fileHistory.more") });
|
||||
more.hidden = true;
|
||||
let oldest = null;
|
||||
|
||||
const workspace = erstelleElement("select", { "aria-label": t("fileHistory.workspace") });
|
||||
workspace.append(erstelleElement("option", { value: "", text: t("fileHistory.allWorkspaces") }));
|
||||
const agent = erstelleElement("input", { type: "text", placeholder: t("fileHistory.agent"), "aria-label": t("fileHistory.agent") });
|
||||
const path = erstelleElement("input", { type: "text", placeholder: t("fileHistory.path"), "aria-label": t("fileHistory.path") });
|
||||
const run = erstelleElement("input", { type: "text", placeholder: t("fileHistory.run"), "aria-label": t("fileHistory.run") });
|
||||
const apply = erstelleElement("button", {
|
||||
type: "submit", klasse: "schaltflaeche primaer", text: t("fileHistory.apply")
|
||||
});
|
||||
const reset = erstelleElement("button", {
|
||||
type: "button", klasse: "schaltflaeche flach", text: t("fileHistory.reset"),
|
||||
onclick: () => {
|
||||
workspace.value = agent.value = path.value = run.value = "";
|
||||
load(true);
|
||||
}
|
||||
});
|
||||
const form = erstelleElement("form", { klasse: "dateiverlauf-filter" }, workspace, agent, path, run, apply, reset);
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
load(true);
|
||||
});
|
||||
|
||||
container.replaceChildren(erstelleElement("div", { klasse: "verbrauch-ansicht" },
|
||||
erstelleElement("h1", { text: t("fileHistory.title") }),
|
||||
erstelleElement("p", { klasse: "hinweis", text: t("fileHistory.intro") }),
|
||||
form, list, more));
|
||||
|
||||
try {
|
||||
for (const name of (await api("/file-history/workspaces")) || []) {
|
||||
workspace.append(erstelleElement("option", { value: name, text: name }));
|
||||
}
|
||||
} catch {
|
||||
// The list works without the choices.
|
||||
}
|
||||
|
||||
more.addEventListener("click", () => load(false));
|
||||
|
||||
/** Filters by a value from a row — the path or the run of an entry. */
|
||||
function filterBy(field, value) {
|
||||
({ path, run, agent })[field].value = value;
|
||||
load(true);
|
||||
}
|
||||
|
||||
async function load(fresh) {
|
||||
Object.assign(filter, {
|
||||
workspace: workspace.value, path: path.value.trim(), agent: agent.value.trim(), run: run.value.trim()
|
||||
});
|
||||
if (fresh) {
|
||||
oldest = null;
|
||||
list.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
|
||||
}
|
||||
const query = new URLSearchParams({ limit: String(PAGE_SIZE) });
|
||||
for (const [key, value] of Object.entries(filter)) if (value) query.set(key, value);
|
||||
if (oldest) query.set("before", String(oldest));
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = (await api(`/file-history?${query}`)) || [];
|
||||
} catch (err) {
|
||||
list.replaceChildren(erstelleKarte(t("fileHistory.unavailable"), [erstelleElement("p", { klasse: "gedaempft", text: err.message })]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (fresh) list.replaceChildren();
|
||||
if (fresh && entries.length === 0) {
|
||||
list.append(erstelleKarte(t("fileHistory.empty.title"), [erstelleElement("p", { klasse: "gedaempft", text: t("fileHistory.empty.text") })]));
|
||||
}
|
||||
for (const entry of entries) list.append(row(entry, filterBy, () => load(true)));
|
||||
if (entries.length > 0) oldest = entries[entries.length - 1].id;
|
||||
more.hidden = entries.length < PAGE_SIZE;
|
||||
}
|
||||
|
||||
await load(true);
|
||||
}
|
||||
|
||||
function row(entry, filterBy, reload) {
|
||||
const who = entry.agentId
|
||||
? erstelleElement("button", {
|
||||
type: "button", klasse: "verknuepfung", text: entry.agentId,
|
||||
onclick: () => filterBy("agent", entry.agentId)
|
||||
})
|
||||
: erstelleElement("span", { klasse: "gedaempft", text: entry.action === "external" ? t("fileHistory.external") : "—" });
|
||||
|
||||
const runLink = entry.runId
|
||||
? erstelleElement("a", { href: `#/prompt-history/${encodeURIComponent(entry.runId)}`, text: t("fileHistory.toRun") })
|
||||
: null;
|
||||
|
||||
return erstelleElement("div", { klasse: "werkzeug-zeile dateiverlauf-zeile" },
|
||||
erstelleElement("div", { klasse: "werkzeug-info" },
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" },
|
||||
erstelleElement("span", { klasse: `dateiverlauf-aktion aktion-${entry.action}`, text: t(`fileHistory.action.${entry.action}`) }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "verknuepfung dateiverlauf-pfad", text: entry.path,
|
||||
title: t("fileHistory.onlyThisFile"),
|
||||
onclick: () => filterBy("path", entry.path)
|
||||
})),
|
||||
erstelleElement("div", { klasse: "werkzeug-beschreibung gedaempft" },
|
||||
`${formatiereDatumZeit(entry.occurredAtUtc)} · ${entry.workspace} · `, who,
|
||||
runLink ? " · " : null, runLink,
|
||||
entry.note ? ` · ${entry.note}` : null)),
|
||||
erstelleElement("div", { klasse: "werkzeug-aktionen" },
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "schaltflaeche flach klein", text: t("fileHistory.show"),
|
||||
onclick: () => showEntry(entry, reload)
|
||||
})));
|
||||
}
|
||||
|
||||
/** Before and after of one change, as a diff where both are text, and putting either back. */
|
||||
async function showEntry(entry, reload) {
|
||||
const body = erstelleElement("div", { klasse: "dateiverlauf-detail" },
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
|
||||
const restoreBefore = erstelleElement("button", {
|
||||
type: "button", klasse: "schaltflaeche flach", text: t("fileHistory.restoreBefore"),
|
||||
onclick: () => restore(entry, "before", modal, reload)
|
||||
});
|
||||
const restoreAfter = erstelleElement("button", {
|
||||
type: "button", klasse: "schaltflaeche flach", text: t("fileHistory.restoreAfter"),
|
||||
onclick: () => restore(entry, "after", modal, reload)
|
||||
});
|
||||
restoreBefore.disabled = !entry.hasBefore;
|
||||
restoreAfter.disabled = !entry.hasAfter;
|
||||
const close = erstelleElement("button", { type: "button", klasse: "schaltflaeche primaer", text: t("common.close"), onclick: () => modal.schliessen() });
|
||||
const modal = erstelleModal(`${t(`fileHistory.action.${entry.action}`)}: ${entry.path}`, body, [restoreBefore, restoreAfter, close], { klasse: "modal-breit" });
|
||||
modal.oeffnen();
|
||||
|
||||
let before, after;
|
||||
try {
|
||||
[before, after] = await Promise.all([
|
||||
api(`/file-history/${entry.id}/content?version=before`),
|
||||
api(`/file-history/${entry.id}/content?version=after`)
|
||||
]);
|
||||
} catch (err) {
|
||||
body.replaceChildren(erstelleElement("p", { klasse: "fehler", text: err.message }));
|
||||
return;
|
||||
}
|
||||
// Only a version that was kept can be put back.
|
||||
restoreBefore.disabled = !entry.hasBefore || !before.kept;
|
||||
restoreAfter.disabled = !entry.hasAfter || !after.kept;
|
||||
|
||||
const describe = (version, present) => !present ? t("fileHistory.absent")
|
||||
: !version.kept ? t("fileHistory.notKept")
|
||||
: version.tooLarge ? t("fileHistory.tooLarge", { bytes: version.bytes })
|
||||
: version.binary ? t("fileHistory.binary", { bytes: version.bytes })
|
||||
: null;
|
||||
|
||||
const beforeNote = describe(before, entry.hasBefore);
|
||||
const afterNote = describe(after, entry.hasAfter);
|
||||
const parts = [];
|
||||
if (beforeNote || afterNote) {
|
||||
parts.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: `${t("fileHistory.before")}: ${beforeNote ?? t("fileHistory.text")} · ${t("fileHistory.after")}: ${afterNote ?? t("fileHistory.text")}` }));
|
||||
}
|
||||
if (before.text != null || after.text != null) {
|
||||
parts.push(diffView(before.text ?? "", after.text ?? ""));
|
||||
}
|
||||
body.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
async function restore(entry, version, modal, reload) {
|
||||
const reason = erstelleElement("textarea", { rows: "3", "aria-label": t("fileHistory.reason"), placeholder: t("fileHistory.reasonPlaceholder") });
|
||||
const confirm = erstelleElement("button", { type: "button", klasse: "schaltflaeche primaer", text: t("fileHistory.restore") });
|
||||
const cancel = erstelleElement("button", { type: "button", klasse: "schaltflaeche flach", text: t("common.cancel") });
|
||||
const ask = erstelleModal(t("fileHistory.restoreTitle", { path: entry.path }), erstelleElement("div", { klasse: "formular-raster" },
|
||||
erstelleElement("p", { klasse: "gedaempft", text: t(version === "before" ? "fileHistory.restoreBeforeHint" : "fileHistory.restoreAfterHint") }),
|
||||
erstelleElement("label", { text: t("fileHistory.reason") }), reason), [cancel, confirm]);
|
||||
cancel.addEventListener("click", () => ask.schliessen());
|
||||
confirm.addEventListener("click", async () => {
|
||||
if (!reason.value.trim()) {
|
||||
zeigeMeldung(t("fileHistory.reasonMissing"), "warnung");
|
||||
return;
|
||||
}
|
||||
confirm.disabled = true;
|
||||
try {
|
||||
await api(`/file-history/${entry.id}/restore`, { method: "POST", body: { version, reason: reason.value.trim() } });
|
||||
zeigeMeldung(t("fileHistory.restored", { path: entry.path }), "info");
|
||||
ask.schliessen();
|
||||
modal.schliessen();
|
||||
reload();
|
||||
} catch (err) {
|
||||
zeigeMeldung(t("fileHistory.failed", { error: err.message }), "gefahr");
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
ask.oeffnen();
|
||||
}
|
||||
|
||||
// ─── Diff ───
|
||||
|
||||
/** Lines of both versions, marked as kept, removed or added — a longest-common-subsequence diff. */
|
||||
export function diffLines(beforeText, afterText) {
|
||||
const a = beforeText === "" ? [] : beforeText.split("\n");
|
||||
const b = afterText === "" ? [] : afterText.split("\n");
|
||||
if (a.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES) return null;
|
||||
|
||||
// Common start and end first: most changes touch a small part of a file.
|
||||
let start = 0;
|
||||
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
||||
let endA = a.length, endB = b.length;
|
||||
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
|
||||
|
||||
const midA = a.slice(start, endA), midB = b.slice(start, endB);
|
||||
const n = midA.length, m = midB.length;
|
||||
const lcs = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i][j] = midA[i] === midB[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const out = a.slice(0, start).map((line) => ({ kind: "same", line }));
|
||||
let i = 0, j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (midA[i] === midB[j]) { out.push({ kind: "same", line: midA[i] }); i++; j++; }
|
||||
else if (lcs[i + 1][j] >= lcs[i][j + 1]) { out.push({ kind: "removed", line: midA[i] }); i++; }
|
||||
else { out.push({ kind: "added", line: midB[j] }); j++; }
|
||||
}
|
||||
while (i < n) out.push({ kind: "removed", line: midA[i++] });
|
||||
while (j < m) out.push({ kind: "added", line: midB[j++] });
|
||||
for (const line of a.slice(endA)) out.push({ kind: "same", line });
|
||||
return out;
|
||||
}
|
||||
|
||||
function diffView(beforeText, afterText) {
|
||||
const lines = diffLines(beforeText, afterText);
|
||||
if (!lines) {
|
||||
return erstelleElement("div", { klasse: "dateiverlauf-seiten" },
|
||||
erstelleElement("pre", { klasse: "dateiverlauf-text", text: beforeText }),
|
||||
erstelleElement("pre", { klasse: "dateiverlauf-text", text: afterText }));
|
||||
}
|
||||
const sign = { same: " ", removed: "- ", added: "+ " };
|
||||
return erstelleElement("pre", { klasse: "dateiverlauf-diff" },
|
||||
...lines.map((l) => erstelleElement("span", { klasse: `diff-${l.kind}`, text: `${sign[l.kind]}${l.line}` })));
|
||||
}
|
||||
Reference in New Issue
Block a user