The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
import { useLatest } from "./useLatest";
|
|
|
|
// A generic, reusable undo/redo container for a single piece of state.
|
|
//
|
|
// It keeps a past/present/future snapshot stack: `set` records the current value into the
|
|
// past and clears the redo stack; `undo`/`redo` walk the stack. Every value change (set,
|
|
// undo, redo) calls `onApply(value)` — the side effect that makes the change real (e.g.
|
|
// persist to the server). `reset` installs a fresh value and clears history WITHOUT calling
|
|
// onApply (use it when the underlying data is reloaded from the source of truth).
|
|
//
|
|
// Not playlist-specific: any component with an undoable value (sort order, a draft, a set
|
|
// of filters…) can use it. The history is state (what render reads) mirrored in a ref (what the
|
|
// stable callbacks read, so two calls in one tick chain on each other, never on a stale snapshot —
|
|
// save after a `reset`, which is state only and reaches the mirror at its commit; see `reset`).
|
|
// Render never reads the ref: a compiled component would otherwise memoize a stale value.
|
|
export interface Undoable<T> {
|
|
value: T;
|
|
set: (next: T) => void;
|
|
reset: (value: T) => void;
|
|
rebase: (map: (value: T) => T) => void;
|
|
undo: () => void;
|
|
redo: () => void;
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
}
|
|
|
|
interface History<T> {
|
|
past: T[];
|
|
present: T;
|
|
future: T[];
|
|
}
|
|
|
|
export function useUndoable<T>(
|
|
initial: T,
|
|
opts?: { onApply?: (value: T) => void; limit?: number },
|
|
): Undoable<T> {
|
|
const [h, setH] = useState<History<T>>(() => ({ past: [], present: initial, future: [] }));
|
|
const ref = useRef(h);
|
|
// The mirror follows every commit, so a `reset` made during render (a caller re-seeding on a new
|
|
// selection) reaches the callbacks without a render-phase ref write.
|
|
useLayoutEffect(() => {
|
|
ref.current = h;
|
|
}, [h]);
|
|
const commit = useCallback((next: History<T>) => {
|
|
ref.current = next;
|
|
setH(next);
|
|
}, []);
|
|
const onApplyRef = useLatest(opts?.onApply);
|
|
const limit = opts?.limit ?? 100;
|
|
|
|
const set = useCallback(
|
|
(next: T) => {
|
|
const s = ref.current;
|
|
if (Object.is(next, s.present)) return;
|
|
const past = [...s.past, s.present];
|
|
if (past.length > limit) past.shift();
|
|
commit({ past, present: next, future: [] });
|
|
onApplyRef.current?.(next);
|
|
},
|
|
[limit, commit, onApplyRef],
|
|
);
|
|
|
|
const undo = useCallback(() => {
|
|
const s = ref.current;
|
|
if (!s.past.length) return;
|
|
const prev = s.past[s.past.length - 1]!; // past is non-empty (guarded above)
|
|
commit({
|
|
past: s.past.slice(0, -1),
|
|
present: prev,
|
|
future: [s.present, ...s.future],
|
|
});
|
|
onApplyRef.current?.(prev);
|
|
}, [commit, onApplyRef]);
|
|
|
|
const redo = useCallback(() => {
|
|
const s = ref.current;
|
|
if (!s.future.length) return;
|
|
const next = s.future[0]!; // future is non-empty (guarded above)
|
|
const rest = s.future.slice(1);
|
|
commit({ past: [...s.past, s.present], present: next, future: rest });
|
|
onApplyRef.current?.(next);
|
|
}, [commit, onApplyRef]);
|
|
|
|
// State only: safe to call during render. `rebase` builds on it at once; `set`/`undo`/`redo` read
|
|
// the mirror, so they see it from the next commit on (no caller acts in the tick of a reset — a
|
|
// set, undo or redo in that tick would commit the history from before it).
|
|
const reset = useCallback((value: T) => {
|
|
setH({ past: [], present: value, future: [] });
|
|
}, []);
|
|
|
|
// Apply the same transformation to EVERY snapshot — past, present and future — and keep the
|
|
// history. For changes that are outside the undoable dimension but invalidate the snapshots:
|
|
// removing a playlist item is not itself undoable, yet dropping it from each stored order keeps
|
|
// the user's reorder history valid and usable instead of throwing it away (which is what a
|
|
// `reset` does). No `onApply`: the caller is already persisting the change by its own route.
|
|
// The STATE through an updater: a `reset` earlier in the same tick is state only, so the mirror
|
|
// still held the history before it, and rebasing that wrote the old value back over the reset (a
|
|
// cached playlist remounted under StrictMode re-seeds with reset-then-rebase and painted empty).
|
|
// The mirror is rebased too, so an `undo` later in the same tick steps back to a rebased snapshot,
|
|
// not to one that still holds the removed row. Called from handlers and
|
|
// effects only, never during render.
|
|
const rebase = useCallback((map: (value: T) => T) => {
|
|
const over = (s: History<T>) => ({
|
|
past: s.past.map(map),
|
|
present: map(s.present),
|
|
future: s.future.map(map),
|
|
});
|
|
ref.current = over(ref.current);
|
|
setH(over);
|
|
}, []);
|
|
|
|
return {
|
|
value: h.present,
|
|
set,
|
|
reset,
|
|
rebase,
|
|
undo,
|
|
redo,
|
|
canUndo: h.past.length > 0,
|
|
canRedo: h.future.length > 0,
|
|
};
|
|
}
|