feat(project-editor): session undo/redo and modified-field tracking

Milestone 4 of the Configuration Engine sprint.

- Add schema/history.util: pure undo/redo reducer (commit/undo/redo, depth cap)
  with full spec coverage.
- Facade: debounced snapshot history (~300ms coalesce so a typing burst = one
  undo step); undo()/redo() route through the draft-save path so autosave never
  desyncs; canUndo/canRedo; history cleared on load/publish/resetDraft.
  modifiedFields (schema-diff vs original) + modifiedSections computeds.
- save-bar: Undo/Redo buttons. Page: Ctrl/Cmd+Z / Shift+Z / Y shortcuts
  (skipped while a text field is focused so native text undo is preserved);
  beforeunload guard already present.
- nav: amber modified-field dot per section (when no blocking badge).
- i18n: builder.undo / builder.redo in interface + en/ru/hy.

Gate: tsc --noEmit, npm test (33/33), arch:check, build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-17 02:13:34 +04:00
parent a7bab6be52
commit 1db0d4dfea
13 changed files with 290 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
import { commit, emptyHistory, redo, undo } from './history.util';
describe('history reducer', () => {
it('starts empty', () => {
const history = emptyHistory<number>();
expect(history.past).toEqual([]);
expect(history.future).toEqual([]);
});
it('commit records an undo point and clears the redo stack', () => {
let history = emptyHistory<number>();
history = commit(history, 1);
history = commit({ ...history, future: [9] }, 2);
expect(history.past).toEqual([1, 2]);
expect(history.future).toEqual([]);
});
it('caps history depth at the limit', () => {
let history = emptyHistory<number>();
for (let i = 1; i <= 5; i++) {
history = commit(history, i, 3);
}
expect(history.past).toEqual([3, 4, 5]);
});
it('undo steps back and moves current onto the redo stack', () => {
const history = { past: [1, 2], future: [] as number[] };
const step = undo(history, 3);
expect(step).not.toBeNull();
expect(step!.value).toBe(2);
expect(step!.history.past).toEqual([1]);
expect(step!.history.future).toEqual([3]);
});
it('undo returns null when there is nothing to undo', () => {
expect(undo(emptyHistory<number>(), 1)).toBeNull();
});
it('redo steps forward and moves current back onto the undo stack', () => {
const history = { past: [1], future: [3] };
const step = redo(history, 2);
expect(step).not.toBeNull();
expect(step!.value).toBe(3);
expect(step!.history.past).toEqual([1, 2]);
expect(step!.history.future).toEqual([]);
});
it('redo returns null when there is nothing to redo', () => {
expect(redo(emptyHistory<number>(), 1)).toBeNull();
});
it('undo then redo round-trips to the same value', () => {
const start = { past: [10, 20], future: [] as number[] };
const undone = undo(start, 30)!;
const redone = redo(undone.history, undone.value)!;
expect(redone.value).toBe(30);
expect(redone.history.past).toEqual([10, 20]);
expect(redone.history.future).toEqual([]);
});
});

View File

@@ -0,0 +1,52 @@
/**
* Pure undo/redo history reducer over immutable snapshots. Framework-agnostic
* so it can be unit-tested in isolation; the facade wraps it with debounced
* commits and Angular signals. `past` is oldest-to-newest; `future` is the
* redo stack, newest-undone first.
*/
export const HISTORY_LIMIT = 50;
export interface History<T> {
readonly past: readonly T[];
readonly future: readonly T[];
}
export interface HistoryStep<T> {
readonly history: History<T>;
readonly value: T;
}
export function emptyHistory<T>(): History<T> {
return { past: [], future: [] };
}
/** Records a snapshot as a new undo point and drops the redo stack. Caps depth at `limit`. */
export function commit<T>(history: History<T>, snapshot: T, limit = HISTORY_LIMIT): History<T> {
const past = [...history.past, snapshot].slice(-limit);
return { past, future: [] };
}
/** Steps back one snapshot, pushing `current` onto the redo stack. Null when nothing to undo. */
export function undo<T>(history: History<T>, current: T): HistoryStep<T> | null {
if (history.past.length === 0) {
return null;
}
const value = history.past[history.past.length - 1];
return {
history: { past: history.past.slice(0, -1), future: [current, ...history.future] },
value,
};
}
/** Steps forward one snapshot, pushing `current` back onto the undo stack. Null when nothing to redo. */
export function redo<T>(history: History<T>, current: T): HistoryStep<T> | null {
if (history.future.length === 0) {
return null;
}
const value = history.future[0];
return {
history: { past: [...history.past, current], future: history.future.slice(1) },
value,
};
}