Restore editor history after reload

This commit is contained in:
2026-07-25 21:45:58 +02:00
parent c45afd0981
commit 4b47bc2bcc
10 changed files with 275 additions and 423 deletions
+79 -383
View File
@@ -36,9 +36,9 @@ import {
import {
buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command";
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import type {
CellKey,
CellKind,
ColumnDef,
RowType,
} from "../utils/circuit-grid-model";
@@ -53,6 +53,7 @@ import {
deleteCircuitDeviceRowCommand,
getCircuitTree,
getNextCircuitIdentifier,
getProjectHistory,
insertCircuitCommand,
insertCircuitDeviceRowCommand,
listProjectDevices,
@@ -73,6 +74,7 @@ import type {
CreateCircuitInputDto,
ProjectCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
} from "../types";
import type {
CircuitDeviceRowSnapshot,
@@ -108,11 +110,6 @@ interface SelectionIntent {
interface HistoryCommand {
label: string;
redo: () => Promise<SelectionIntent | null | void>;
undo: () => Promise<SelectionIntent | null | void>;
persistent?: {
undoIntent: () => SelectionIntent | null;
redoIntent: () => SelectionIntent | null;
};
}
type ProjectDeviceDropIntent =
@@ -214,10 +211,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
// The visible stack is session-local during the cutover. Circuit/device field
// commands already use the persistent project-wide server history.
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
const [historyState, setHistoryState] = useState<ProjectHistoryStateDto | null>(null);
const [historyBusy, setHistoryBusy] = useState(false);
const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null);
const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({});
@@ -255,8 +249,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
setError(null);
try {
const tree = await getCircuitTree(projectId, circuitListId);
projectRevisionRef.current = tree.currentRevision;
const { tree, history } = await loadCircuitEditorSnapshot(
() => getCircuitTree(projectId, circuitListId),
() => getProjectHistory(projectId)
);
projectRevisionRef.current = history.currentRevision;
setHistoryState(history);
setData(tree);
} catch (err) {
setError(normalizeUiError(err));
@@ -279,17 +277,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
void loadTree({ showLoading: true });
}, [projectId, circuitListId]);
async function loadProjectDeviceList() {
const list = await listProjectDevices(projectId);
setProjectDevices(list);
}
// Loads project-device palette used for sidebar drag/drop and quick inserts.
useEffect(() => {
async function loadProjectDeviceList() {
try {
const list = await listProjectDevices(projectId);
setProjectDevices(list);
} catch (err) {
setError(normalizeUiError(err));
}
}
void loadProjectDeviceList();
void loadProjectDeviceList().catch((err) => {
setError(normalizeUiError(err));
});
}, [projectId]);
useEffect(() => {
@@ -792,6 +789,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
function applyProjectCommandResult(result: ProjectCommandResultDto) {
projectRevisionRef.current = result.history.currentRevision;
setHistoryState(result.history);
setData((current) =>
current
? {
@@ -886,84 +884,67 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
applyProjectCommandResult(result);
}
// Runs a normal command and records it in session-local history.
// Runs a normal command. The server records it in project-wide history.
async function runCommand(command: HistoryCommand) {
try {
setError(null);
setIsSaving(true);
const intent = await command.redo();
await reloadWithIntent(intent ?? null);
// Any new forward command invalidates redo history branch.
setUndoStack((current) => [...current, command]);
setRedoStack([]);
} catch (err) {
setError(normalizeUiError(err));
const message = normalizeUiError(err);
await loadTree({ showLoading: false });
setError(message);
} finally {
setIsSaving(false);
}
}
// Replays command in undo/redo mode. Commands encapsulate their own inverse logic.
async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") {
// Applies the next eligible project-wide history operation. Selection is only
// a best-effort local hint; command eligibility and data changes stay server-owned.
async function applyHistory(mode: "undo" | "redo") {
try {
setError(null);
setHistoryBusy(true);
setIsSaving(true);
let intent: SelectionIntent | null | void;
if (command.persistent) {
const result =
mode === "undo"
? await undoProjectCommand(
projectId,
getExpectedProjectRevision()
)
: await redoProjectCommand(
projectId,
getExpectedProjectRevision()
);
applyProjectCommandResult(result);
intent =
mode === "undo"
? command.persistent.undoIntent()
: command.persistent.redoIntent();
} else {
intent =
mode === "undo"
? await command.undo()
: await command.redo();
}
await reloadWithIntent(intent ?? null);
if (mode === "undo") {
setUndoStack((current) => current.slice(0, -1));
setRedoStack((current) => [...current, command]);
} else {
setRedoStack((current) => current.slice(0, -1));
setUndoStack((current) => [...current, command]);
}
const intent = selectedCell ? buildSelectionIntent(selectedCell) : null;
const result =
mode === "undo"
? await undoProjectCommand(
projectId,
getExpectedProjectRevision()
)
: await redoProjectCommand(
projectId,
getExpectedProjectRevision()
);
applyProjectCommandResult(result);
await Promise.all([
reloadWithIntent(intent),
loadProjectDeviceList(),
]);
} catch (err) {
setError(normalizeUiError(err));
const message = normalizeUiError(err);
await loadTree({ showLoading: false });
setError(message);
} finally {
setIsSaving(false);
setHistoryBusy(false);
}
}
// Undo command from session-local history stack.
async function handleUndo() {
if (historyBusy || isSaving || undoStack.length === 0) {
if (historyBusy || isSaving || !historyState || historyState.undoDepth === 0) {
return;
}
const command = undoStack[undoStack.length - 1];
await applyHistory(command, "undo");
await applyHistory("undo");
}
// Redo command from session-local history stack.
async function handleRedo() {
if (historyBusy || isSaving || redoStack.length === 0) {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
}
const command = redoStack[redoStack.length - 1];
await applyHistory(command, "redo");
await applyHistory("redo");
}
// Persists currently sorted block order into section sortOrder values.
@@ -1029,11 +1010,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setOpenFilterColumn(null);
return null;
},
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () => null,
},
});
}
@@ -1426,46 +1402,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Placeholder rows are not persisted entities; editing them materializes a real circuit first.
if (row.rowType === "placeholder") {
let createdCircuitId: string | null = null;
const command: HistoryCommand = {
label: "Aus freier Zeile erstellen",
redo: async () => {
const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft);
targetSelection = selection;
const createdRow = selection.rowKey.startsWith("circuitCompact:")
? selection.rowKey.replace("circuitCompact:", "")
: selection.rowKey.replace("reserveCircuit:", "");
createdCircuitId = createdRow;
return nextSelectionIntent();
},
undo: async () => {
return {
rowKey: `placeholder:${row.sectionId}`,
cellKey: editingCell.cellKey,
rowType: "placeholder",
sectionId: row.sectionId,
};
},
persistent: {
undoIntent: () => ({
rowKey: `placeholder:${row.sectionId}`,
cellKey: editingCell.cellKey,
rowType: "placeholder",
sectionId: row.sectionId,
}),
redoIntent: () =>
createdCircuitId
? {
rowKey: targetSelection.rowKey,
cellKey: editingCell.cellKey,
rowType: targetSelection.rowKey.startsWith("circuitCompact:")
? "circuitCompact"
: "reserveCircuit",
sectionId: row.sectionId,
circuitId: createdCircuitId,
}
: null,
},
};
setEditingCell(null);
await runCommand(command);
@@ -1480,19 +1423,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
undo: async () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
persistent: {
undoIntent: () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
redoIntent: nextSelectionIntent,
},
};
setEditingCell(null);
await runCommand(command);
@@ -1507,19 +1437,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
undo: async () =>
buildSelectionIntent({
rowKey: `device:${row.device!.id}`,
cellKey: editingCell.cellKey,
}),
persistent: {
undoIntent: () =>
buildSelectionIntent({
rowKey: `device:${row.device!.id}`,
cellKey: editingCell.cellKey,
}),
redoIntent: nextSelectionIntent,
},
};
setEditingCell(null);
await runCommand(command);
@@ -1528,7 +1445,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
const next = editingCell.draft;
let createdRowId: string | null = null;
const command: HistoryCommand = {
label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => {
@@ -1553,37 +1469,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
snapshot,
"Gerätezeile im Reservestromkreis erstellen"
);
createdRowId = snapshot.id;
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent();
},
undo: async () => ({
rowKey: `reserveCircuit:${row.circuit!.id}`,
cellKey: editingCell.cellKey,
rowType: "reserveCircuit",
sectionId: row.sectionId,
circuitId: row.circuit!.id,
}),
persistent: {
undoIntent: () => ({
rowKey: `reserveCircuit:${row.circuit!.id}`,
cellKey: editingCell.cellKey,
rowType: "reserveCircuit",
sectionId: row.sectionId,
circuitId: row.circuit!.id,
}),
redoIntent: () =>
createdRowId
? {
rowKey: `circuitCompact:${row.circuit!.id}`,
cellKey: editingCell.cellKey,
rowType: "circuitCompact",
sectionId: row.sectionId,
circuitId: row.circuit!.id,
deviceId: createdRowId,
}
: null,
},
};
setEditingCell(null);
await runCommand(command);
@@ -1605,7 +1493,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) {
return;
}
let createdCircuitId: string | null = null;
await runCommand({
label: "Stromkreis hinzufügen",
redo: async () => {
@@ -1619,7 +1506,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
isReserve: true,
});
await persistCircuitInsert(circuit, "Stromkreis hinzufügen");
createdCircuitId = circuit.id;
setActiveSectionId(sectionId);
return {
rowKey: `reserveCircuit:${circuit.id}`,
@@ -1629,30 +1515,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
circuitId: circuit.id,
};
},
undo: async () => ({
rowKey: `placeholder:${sectionId}`,
cellKey: "equipmentIdentifier",
rowType: "placeholder",
sectionId,
}),
persistent: {
undoIntent: () => ({
rowKey: `placeholder:${sectionId}`,
cellKey: "equipmentIdentifier",
rowType: "placeholder",
sectionId,
}),
redoIntent: () =>
createdCircuitId
? {
rowKey: `reserveCircuit:${createdCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "reserveCircuit",
sectionId,
circuitId: createdCircuitId,
}
: null,
},
});
}
@@ -1663,32 +1525,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
) {
const originalDeviceCount = circuit.deviceRows.length;
const section = data?.sections.find((entry) => entry.id === sectionId);
let createdRowId: string | null = null;
const getUndoIntent = (): SelectionIntent => {
const rowKey =
originalDeviceCount === 0
? `reserveCircuit:${circuit.id}`
: originalDeviceCount === 1
? `circuitCompact:${circuit.id}`
: afterDeviceRowId
? `device:${afterDeviceRowId}`
: `circuitSummary:${circuit.id}`;
return {
rowKey,
cellKey: "displayName",
rowType:
originalDeviceCount === 0
? "reserveCircuit"
: originalDeviceCount === 1
? "circuitCompact"
: afterDeviceRowId
? "deviceRow"
: "circuitSummary",
sectionId,
circuitId: circuit.id,
deviceId: afterDeviceRowId,
};
};
await runCommand({
label: "Manuelles Gerät hinzufügen",
redo: async () => {
@@ -1706,7 +1542,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
rowSnapshot,
"Manuelles Gerät hinzufügen"
);
createdRowId = rowSnapshot.id;
setActiveSectionId(sectionId);
return {
rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${rowSnapshot.id}`,
@@ -1717,27 +1552,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: rowSnapshot.id,
};
},
undo: async () => getUndoIntent(),
persistent: {
undoIntent: getUndoIntent,
redoIntent: () =>
createdRowId
? {
rowKey:
originalDeviceCount === 0
? `circuitCompact:${circuit.id}`
: `device:${createdRowId}`,
cellKey: "displayName",
rowType:
originalDeviceCount === 0
? "circuitCompact"
: "deviceRow",
sectionId,
circuitId: circuit.id,
deviceId: createdRowId,
}
: null,
},
});
}
@@ -1816,14 +1630,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError(getProjectDeviceTargetError(device, targetSectionId));
return;
}
let createdCircuitId: string | null = null;
let createdRowId: string | null = null;
await runCommand({
label: "Projektgerät als neuen Stromkreis hinzufügen",
redo: async () => {
const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId);
createdCircuitId = created.circuitId;
createdRowId = created.rowId;
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
@@ -1833,31 +1643,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId,
};
},
undo: async () => ({
rowKey: `placeholder:${targetSectionId}`,
cellKey: "displayName",
rowType: "placeholder",
sectionId: targetSectionId,
}),
persistent: {
undoIntent: () => ({
rowKey: `placeholder:${targetSectionId}`,
cellKey: "displayName",
rowType: "placeholder",
sectionId: targetSectionId,
}),
redoIntent: () =>
createdCircuitId && createdRowId
? {
rowKey: `device:${createdRowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: targetSectionId,
circuitId: createdCircuitId,
deviceId: createdRowId,
}
: null,
},
});
}
@@ -1884,12 +1669,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
let createdRowId: string | null = null;
await runCommand({
label: "Projektgerät zum Stromkreis hinzufügen",
redo: async () => {
const created = await insertProjectDeviceToCircuit(device, circuitId);
createdRowId = created.rowId;
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
@@ -1899,33 +1682,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId,
};
},
undo: async () => ({
rowKey: `circuitSummary:${circuitId}`,
cellKey: "displayName",
rowType: "circuitSummary",
sectionId,
circuitId,
}),
persistent: {
undoIntent: () => ({
rowKey: `circuitSummary:${circuitId}`,
cellKey: "displayName",
rowType: "circuitSummary",
sectionId,
circuitId,
}),
redoIntent: () =>
createdRowId
? {
rowKey: `device:${createdRowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId,
circuitId,
deviceId: createdRowId,
}
: null,
},
});
}
@@ -2190,14 +1946,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Project-device drop logic is isolated from row/circuit move logic because
// it can create new rows/circuits instead of moving existing ones.
if (intent.kind === "new-circuit") {
let createdCircuitId: string | null = null;
let createdRowId: string | null = null;
await runCommand({
label: "Projektgerät in neuen Stromkreis ziehen",
redo: async () => {
const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId);
createdCircuitId = created.circuitId;
createdRowId = created.rowId;
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
@@ -2207,30 +1959,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId,
};
},
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () =>
createdCircuitId && createdRowId
? {
rowKey: `device:${createdRowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: intent.sectionId,
circuitId: createdCircuitId,
deviceId: createdRowId,
}
: null,
},
});
return;
}
let createdRowId: string | null = null;
await runCommand({
label: "Projektgerät in Stromkreis ziehen",
redo: async () => {
const created = await insertProjectDeviceToCircuit(device, intent.circuitId);
createdRowId = created.rowId;
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
@@ -2240,21 +1975,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId,
};
},
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () =>
createdRowId
? {
rowKey: `device:${createdRowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: intent.sectionId,
circuitId: intent.circuitId,
deviceId: createdRowId,
}
: null,
},
});
}
@@ -2335,17 +2055,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
undo: async () => null,
persistent: {
undoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
redoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
},
});
return;
}
@@ -2398,17 +2107,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
undo: async () => null,
persistent: {
undoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
redoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
},
});
}
@@ -2477,19 +2175,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
return selectionIntent;
},
undo: async () => null,
persistent: {
undoIntent: () => {
pendingSelectedCircuitIdsAfterReload.current =
sourceCircuitIds;
return selectionIntent;
},
redoIntent: () => {
pendingSelectedCircuitIdsAfterReload.current =
sourceCircuitIds;
return selectionIntent;
},
},
});
}
@@ -2533,11 +2218,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
);
return null;
},
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () => null,
},
});
}
@@ -2563,11 +2243,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await persistCircuitDelete(circuitId, "Stromkreis löschen");
return null;
},
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () => null,
},
});
}
@@ -2625,11 +2300,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
applyProjectCommandResult(result);
return null;
},
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () => null,
},
});
}
@@ -2755,10 +2425,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return (
<div className="tree-editor-shell">
<div className="editor-toolbar">
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}>
<button
type="button"
onClick={() => void handleUndo()}
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
>
Rückgängig
</button>
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
<button
type="button"
onClick={() => void handleRedo()}
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
>
Wiederholen
</button>
<button
@@ -2822,10 +2500,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return (
<div className="tree-editor-shell">
<div className="editor-toolbar">
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}>
<button
type="button"
onClick={() => void handleUndo()}
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
title={
historyState?.undoDepth
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
: undefined
}
>
Rückgängig
</button>
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
<button
type="button"
onClick={() => void handleRedo()}
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
title={
historyState?.redoDepth
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
: undefined
}
>
Wiederholen
</button>
{isSortedView ? (
@@ -0,0 +1,27 @@
import type {
CircuitTreeResponseDto,
ProjectHistoryStateDto,
} from "../types";
export interface CircuitEditorSnapshot {
tree: CircuitTreeResponseDto;
history: ProjectHistoryStateDto;
}
export async function loadCircuitEditorSnapshot(
readTree: () => Promise<CircuitTreeResponseDto>,
readHistory: () => Promise<ProjectHistoryStateDto>,
maxAttempts = 3
): Promise<CircuitEditorSnapshot> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const tree = await readTree();
const history = await readHistory();
if (tree.currentRevision === history.currentRevision) {
return { tree, history };
}
}
throw new Error(
"Der Projektstand hat sich während des Ladens geändert. Bitte erneut versuchen."
);
}