diff --git a/AGENTS.md b/AGENTS.md index 037d46e..da34ece 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -237,9 +237,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. -The project page exposes an initially collapsed German snapshot/timeline UI -with explicit restore confirmation and cursor-based loading of older revision -metadata. +The project page exposes persistent project-wide Undo/Redo in the header of an +initially collapsed German snapshot/timeline UI, with explicit restore +confirmation and cursor-based loading of older revision metadata. Insertions and generated move targets use client-generated stable UUIDs, and undo restores the same ids from complete server snapshots. The tree response supplies the optimistic `currentRevision`; obsolete direct field PATCH, diff --git a/README.md b/README.md index f5a44b2..207e9e0 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, -persistentes Undo/Redo, Projektversionen und der Revit-/IFCGUID-Datenaustausch sind -noch nicht implementiert. +automatische periodische Sicherungspunkte und der Revit-/IFCGUID-Datenaustausch +sind noch nicht implementiert. ## Unterstützter Arbeitsablauf @@ -18,7 +18,8 @@ noch nicht implementiert. - Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren - komplette Stromkreisblöcke filtern und sortieren - BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren -- Änderungen innerhalb der aktuellen Editorsitzung rückgängig machen und wiederholen +- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen +- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen ## Technik diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index d4d3b95..8fc7ded 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -142,6 +142,7 @@ Covered operations include: - renumber and identifier update flows - apply sorted order -The toolbar currently exposes availability through its Undo/Redo buttons. -Named restore points and the revision timeline are available on the project -page; the circuit editor keeps only the compact Undo/Redo controls. +The editor toolbar exposes availability through its Undo/Redo buttons. The +project page provides the same persistent project-wide controls in the header +of its version card, including after a reload. Named restore points and the +revision timeline remain inside the expandable part of that card. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index ba7e160..a14ee45 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -121,7 +121,10 @@ Die Projektseite bindet diese APIs in einem einklappbaren Bereich „Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte benennen, nach expliziter Bestätigung wiederherstellen und die paginierte Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen. -Nach einem Restore lädt die Seite sämtliche Projektdaten neu. +Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im +eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den +persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt +die Seite sämtliche Projektdaten neu. `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; @@ -157,8 +160,9 @@ einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit -optimistischer Revisionsprüfung. Das dortige Rückgängig löst die persistente -projektweite Historie aus; separate direkte Restore-/Reconnect-Schreibwege +optimistischer Revisionsprüfung. Rückgängig/Wiederholen erfolgt dort +einheitlich über die persistente projektweite Historie; der frühere +sitzungslokale Spezial-Undo und separate direkte Restore-/Reconnect-Schreibwege existieren nicht mehr. `project-device.update` versioniert Änderungen an den kanonischen Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem @@ -212,8 +216,7 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. ## Noch nicht unterstützt -- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und - benannte Projektstände +- automatische periodische Projekt-Snapshots und Aufbewahrungsrichtlinie - Mehrbenutzerbetrieb und Konfliktauflösung - Revit-/CSV-/IFCGUID-Round-trip - vollständige elektrische Dimensionierung diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index c63aa95..a3ccbe3 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -373,6 +373,8 @@ Tasks: persistent undo/redo - [x] expose named snapshots, confirmed restore and the paginated revision timeline on the project page +- [x] expose persistent project-wide undo/redo controls on the project page + and remove its session-local ProjectDevice synchronization undo state - keep database backups separate from logical history Implemented foundation: @@ -401,6 +403,8 @@ Implemented foundation: undo/redo stacks - the project page lists, creates and explicitly restores named snapshots and displays paginated revision metadata without exposing command payloads +- the version-card header reads persisted undo/redo eligibility and executes + optimistic project-wide undo/redo even while its timeline body is collapsed - a central application dispatcher reconstructs persisted commands and exposes optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow field updates diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index c2ac898..6b8cab4 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -21,7 +21,6 @@ import { listProjects, listRooms, synchronizeProjectDeviceRows, - undoProjectCommand, updateProjectDevice, updateProjectSettings, } from "../../../frontend/utils/api"; @@ -56,11 +55,6 @@ const projectDeviceSyncFieldLabels: Record = { remark: "Bemerkung", }; -type ProjectDeviceUndoState = { - projectDeviceId: string; - revisionNumber: number; -}; - const emptyProjectDevice: CreateProjectDeviceInput = { name: "", displayName: "", @@ -117,7 +111,6 @@ export default function ProjectDetailPage() { const [syncPreview, setSyncPreview] = useState(null); const [selectedSyncRowIds, setSelectedSyncRowIds] = useState([]); const [selectedSyncFields, setSelectedSyncFields] = useState([]); - const [projectDeviceUndo, setProjectDeviceUndo] = useState(null); const [error, setError] = useState(null); const [isSaving, setIsSaving] = useState(false); @@ -180,7 +173,6 @@ export default function ProjectDetailPage() { setProject((current) => current ? { ...current, currentRevision } : current ); - setProjectDeviceUndo(null); } async function handleCreateBoard(event: FormEvent) { @@ -386,7 +378,6 @@ export default function ProjectDetailPage() { return; } try { - setProjectDeviceUndo(null); const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId); setSyncPreview(preview); setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)); @@ -438,10 +429,6 @@ export default function ProjectDetailPage() { setSelectedSyncRowIds( result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId) ); - setProjectDeviceUndo({ - projectDeviceId: syncPreview.projectDevice.id, - revisionNumber: result.revision.revisionNumber, - }); } catch (err) { setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden."); } finally { @@ -474,10 +461,6 @@ export default function ProjectDetailPage() { .map((row) => row.rowId) ); applyProjectRevision(result.history.currentRevision); - setProjectDeviceUndo({ - projectDeviceId: syncPreview.projectDevice.id, - revisionNumber: result.revision.revisionNumber, - }); } catch (err) { setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden."); } finally { @@ -485,40 +468,6 @@ export default function ProjectDetailPage() { } } - async function handleUndoProjectDeviceOperation() { - if (!projectId || !project || !projectDeviceUndo) { - return; - } - setIsSaving(true); - setError(null); - try { - if ( - project.currentRevision !== - projectDeviceUndo.revisionNumber - ) { - throw new Error( - "Eine neuere Projektänderung verhindert dieses Rückgängig." - ); - } - const result = await undoProjectCommand( - projectId, - project.currentRevision - ); - const preview = await getProjectDeviceSyncPreview( - projectId, - projectDeviceUndo.projectDeviceId - ); - applyProjectRevision(result.history.currentRevision); - setSyncPreview(preview); - setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)); - setProjectDeviceUndo(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Die letzte Aktion konnte nicht rückgängig gemacht werden."); - } finally { - setIsSaving(false); - } - } - async function handleCopyGlobalToProject() { if (!projectId || !project || !selectedGlobalDeviceId) { return; @@ -624,7 +573,7 @@ export default function ProjectDetailPage() {
window.location.reload()} + onProjectStateChange={() => window.location.reload()} projectId={projectId} />
@@ -1163,16 +1112,6 @@ export default function ProjectDetailPage() {
- {projectDeviceUndo ? ( - - ) : null} +
+ {error ? ( +
+ {error} +
+ ) : null} {isOpen ? ( <>
@@ -237,12 +332,6 @@ export function ProjectVersionHistory({
- {error ? ( -
- {error} -
- ) : null} -

Gespeicherte Sicherungspunkte

{isLoading ? ( diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 6cbeeeb..6ef96be 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -6,9 +6,12 @@ import { ProjectVersionHistory } from "../src/frontend/components/project-versio import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js"; import { createNamedProjectSnapshot, + getProjectHistory, listProjectRevisions, listProjectSnapshots, + redoProjectCommand, restoreProjectSnapshot, + undoProjectCommand, } from "../src/frontend/utils/api.js"; import { getProjectRevisionDescription, @@ -40,11 +43,13 @@ describe("project version history presentation", () => { createElement(ProjectVersionHistory, { projectId: "project-1", currentRevision: 42, - onRestoreComplete: () => undefined, + onProjectStateChange: () => undefined, }) ); assert.match(markup, /Versionen und Sicherungspunkte/); assert.match(markup, /Aktuelle Revision 42/); + assert.match(markup, /Rückgängig/); + assert.match(markup, /Wiederholen/); assert.match(markup, /Anzeigen/); assert.doesNotMatch(markup, /Stand speichern/); }); @@ -108,6 +113,7 @@ describe("project version history API", () => { }; try { + await getProjectHistory("project-1"); await listProjectRevisions("project-1", { limit: 10, beforeRevision: 21, @@ -120,11 +126,18 @@ describe("project version history API", () => { "Vor Ausführung" ); await restoreProjectSnapshot("project-1", "snapshot-1", 22); + await undoProjectCommand("project-1", 23); + await redoProjectCommand("project-1", 24); } finally { globalThis.fetch = originalFetch; } assert.deepEqual(requests, [ + { + url: "/api/projects/project-1/history", + method: "GET", + body: null, + }, { url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21", method: "GET", @@ -149,6 +162,16 @@ describe("project version history API", () => { method: "POST", body: { expectedRevision: 22 }, }, + { + url: "/api/projects/project-1/history/undo", + method: "POST", + body: { expectedRevision: 23 }, + }, + { + url: "/api/projects/project-1/history/redo", + method: "POST", + body: { expectedRevision: 24 }, + }, ]); }); });