ompchamber 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +24 -5
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/{index-prbmx3yj.js → index-8t70jhyj.js} +643 -617
  5. package/dist/client/static/js/index-n4wkqj7t.css +1 -0
  6. package/package.json +1 -1
  7. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  8. package/src/client/components/common/code-editor/handler.ts +175 -0
  9. package/src/client/components/common/code-editor/index.tsx +185 -220
  10. package/src/client/components/common/code-surface/index.tsx +109 -12
  11. package/src/client/components/common/lazy-panels.tsx +5 -1
  12. package/src/client/components/common/viewport.ts +5 -3
  13. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  14. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  15. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  16. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  17. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  18. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  19. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  20. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  21. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  22. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  23. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  24. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  25. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  26. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  27. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  28. package/src/client/components/workspace/editor/index.tsx +153 -53
  29. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  30. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  31. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  32. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  33. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  34. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  35. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  36. package/src/client/hooks/editor/scroll-container.ts +26 -0
  37. package/src/client/hooks/editor/use-code-window.ts +2 -20
  38. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  39. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  40. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  41. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  42. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  43. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  44. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  45. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  46. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  47. package/src/client/hooks/workspace/session-todos.ts +102 -0
  48. package/src/client/tailwind.css +30 -0
  49. package/src/server/lib/omp/session/todos.ts +100 -0
  50. package/src/server/routes/fs/action-create.test.ts +90 -0
  51. package/src/server/routes/fs/action.ts +23 -0
  52. package/src/server/routes/omp/index.ts +2 -0
  53. package/src/server/routes/omp/session-todos.ts +80 -0
  54. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  55. package/src/shared/lib/chat/todo/progress.ts +97 -0
  56. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  57. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  58. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  59. package/src/shared/lib/code/editor/commands.ts +260 -0
  60. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  61. package/src/shared/lib/code/editor/edits.ts +122 -0
  62. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  63. package/src/shared/lib/code/editor/find-status.ts +36 -0
  64. package/src/shared/lib/code/editor/find.test.ts +203 -0
  65. package/src/shared/lib/code/editor/find.ts +224 -0
  66. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  67. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  68. package/src/shared/lib/code/editor/keymap.ts +151 -0
  69. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  70. package/src/shared/lib/code/editor/layers.ts +67 -0
  71. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  72. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  73. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  74. package/src/shared/lib/code/editor/lines.ts +83 -0
  75. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  76. package/src/shared/lib/code/editor/marks.ts +106 -0
  77. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  78. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  79. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  80. package/src/shared/lib/code/editor/palette.ts +92 -0
  81. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  82. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  83. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  84. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  85. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  86. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  87. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  88. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  89. package/src/shared/lib/ui/key-binding.ts +143 -0
  90. package/src/shared/lib/util/platform.test.ts +87 -0
  91. package/src/shared/lib/util/platform.ts +30 -0
  92. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  93. package/src/shared/lib/workspace/keymap.ts +30 -0
  94. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  95. package/src/shared/lib/workspace/right-panels.ts +8 -0
  96. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  97. package/src/shared/types/index.ts +1 -0
  98. package/src/shared/types/todo.ts +108 -0
  99. package/dist/client/static/js/index-ranztes9.css +0 -1
  100. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's find/replace state machine.
8
+ *
9
+ * Owns the query, the option toggles, which match is "current", and the two
10
+ * write paths (replace one, replace all). Every rule it applies lives in
11
+ * `shared/lib/code/editor/find`, so the count in the widget, the marks painted
12
+ * on the surface and the bytes written back are the same list — the failure
13
+ * this shape exists to prevent is a widget that says "3 of 7" over highlights
14
+ * that disagree.
15
+ *
16
+ * The query and the toggles persist per session; whether the bar is OPEN does
17
+ * not. A reload that reopened a find bar over the file would be noise the user
18
+ * never asked for, and the same reasoning keeps the btw panel unpersisted.
19
+ */
20
+
21
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
22
+
23
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
24
+ import {
25
+ DEFAULT_FIND_OPTIONS,
26
+ findMatches,
27
+ matchIndexAtOrAfter,
28
+ replaceAllMatches,
29
+ replaceMatch,
30
+ stepMatchIndex,
31
+ type FindOptions,
32
+ } from '@/shared/lib/code/editor/find';
33
+ import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
34
+
35
+ export interface UseEditorFindOptions {
36
+ /** The buffer on screen. */
37
+ text: string;
38
+ /** Identity of the open document — a switch re-seeds the current match. */
39
+ documentKey: string;
40
+ /** Current selection, used to seed the query the way VS Code does. */
41
+ readSelection: () => { start: number; end: number } | null;
42
+ select: (start: number, end: number, focus?: boolean) => void;
43
+ reveal: (offset: number) => void;
44
+ /** Replace the buffer as ONE undoable edit; focus stays where the caller wants it. */
45
+ applyDocument: (value: string, caretStart: number, caretEnd: number) => void;
46
+ /** ⌥Z / Alt+Z — word wrap belongs to the editor panel, not to this hook. */
47
+ toggleWordWrap?: () => void;
48
+ }
49
+
50
+ export interface EditorFindState {
51
+ open: boolean;
52
+ replaceOpen: boolean;
53
+ query: string;
54
+ replacement: string;
55
+ options: FindOptions;
56
+ matches: readonly { start: number; end: number }[];
57
+ /** The document holds more matches than the cap. */
58
+ truncated: boolean;
59
+ /** The query does not compile as a regex — a different answer from "no matches". */
60
+ invalid: boolean;
61
+ currentIndex: number;
62
+ setQuery: (value: string) => void;
63
+ setReplacement: (value: string) => void;
64
+ openFind: () => void;
65
+ openReplace: () => void;
66
+ /** Expand/collapse the replace row without changing which match is current. */
67
+ toggleReplace: () => void;
68
+ /** Bumped on every "open find" so the widget can refocus its field. */
69
+ focusRequest: number;
70
+ close: () => void;
71
+ /** Dispatch a keymap command (⌘F, F3, ⌥⌘C, …). */
72
+ runCommand: (command: EditorCommand) => void;
73
+ toggleOption: (key: keyof FindOptions) => void;
74
+ step: (direction: 1 | -1) => void;
75
+ replaceCurrent: () => void;
76
+ replaceAll: () => void;
77
+ }
78
+
79
+ /** A seeded query must be one line of real text, like VS Code's. */
80
+ function seedQuery(text: string, selection: { start: number; end: number } | null): string | null {
81
+ if (!selection || selection.start === selection.end) return null;
82
+ const selected = text.slice(selection.start, selection.end);
83
+ if (!selected || selected.includes('\n') || selected.length > 200) return null;
84
+ return selected;
85
+ }
86
+
87
+ export function useEditorFind({
88
+ text,
89
+ documentKey,
90
+ readSelection,
91
+ select,
92
+ reveal,
93
+ applyDocument,
94
+ toggleWordWrap,
95
+ }: UseEditorFindOptions): EditorFindState {
96
+ const [query, setQueryState] = useSessionState<string>('editor.findQuery', '');
97
+ const [replacement, setReplacement] = useSessionState<string>('editor.findReplace', '');
98
+ const [storedOptions, setOptions] = useSessionState<FindOptions>('editor.findOptions', DEFAULT_FIND_OPTIONS);
99
+ // A blob written before an option existed carries only the keys it had, so
100
+ // the read is merged over the defaults rather than trusted wholesale.
101
+ const options = useMemo<FindOptions>(() => ({ ...DEFAULT_FIND_OPTIONS, ...storedOptions }), [storedOptions]);
102
+ const [open, setOpen] = useState(false);
103
+ const [replaceOpen, setReplaceOpen] = useState(false);
104
+ const [currentIndex, setCurrentIndex] = useState(-1);
105
+ /** Bumped by every open request so the widget can refocus its field. */
106
+ const [focusRequest, setFocusRequest] = useState(0);
107
+ /**
108
+ * Bumped whenever the current match must be re-resolved even though the match
109
+ * LIST has not changed — opening the bar on an unchanged query, or switching
110
+ * documents (where the offsets mean something else entirely). Without it the
111
+ * resolve effect never ran and the bar kept pointing at the previous file's
112
+ * index.
113
+ */
114
+ const [resolveRequest, setResolveRequest] = useState(0);
115
+ /** Read outside the callbacks: "already open" must not re-seed the query. */
116
+ const openRef = useRef(false);
117
+ openRef.current = open;
118
+
119
+ const result = useMemo(() => findMatches(text, query, options), [text, query, options]);
120
+
121
+ /**
122
+ * Where the current match is resolved FROM: the caret/anchor a reseed uses.
123
+ * A step and a replacement both move it, which is what keeps "next match"
124
+ * meaning the one after the one just replaced.
125
+ */
126
+ const anchorRef = useRef(0);
127
+ /** The next match-list change re-resolves the current match from the anchor. */
128
+ const reseedRef = useRef(true);
129
+
130
+ // Live mirrors: `reveal`/`select` close over the surface's own layout state,
131
+ // and the effects below must not re-run because a callback identity moved.
132
+ const revealRef = useRef(reveal);
133
+ revealRef.current = reveal;
134
+ const selectRef = useRef(select);
135
+ selectRef.current = select;
136
+
137
+ const reseed = useCallback((anchor: number) => {
138
+ anchorRef.current = anchor;
139
+ reseedRef.current = true;
140
+ }, []);
141
+
142
+ // A new document starts from the top: the offsets of the previous file's
143
+ // match say nothing about this one. The token is what makes the resolve below
144
+ // run when the match list itself is unchanged.
145
+ useEffect(() => {
146
+ reseed(0);
147
+ setResolveRequest((previous) => previous + 1);
148
+ }, [documentKey, reseed]);
149
+
150
+ // Resolve the current match whenever the match list moves under it — a
151
+ // keystroke in the query, a toggle, an edit, a replacement. Only a reseed
152
+ // scrolls: an edit in the document must not yank the view back to a match
153
+ // the user is no longer looking at.
154
+ useEffect(() => {
155
+ const matches = result.matches;
156
+ if (reseedRef.current) {
157
+ reseedRef.current = false;
158
+ const index = matchIndexAtOrAfter(matches, anchorRef.current);
159
+ setCurrentIndex(index);
160
+ if (index >= 0) {
161
+ selectRef.current(matches[index].start, matches[index].end, false);
162
+ revealRef.current(matches[index].start);
163
+ }
164
+ return;
165
+ }
166
+ setCurrentIndex((previous) => {
167
+ if (matches.length === 0) return -1;
168
+ if (previous < 0) return matchIndexAtOrAfter(matches, anchorRef.current);
169
+ return Math.min(previous, matches.length - 1);
170
+ });
171
+ // eslint-disable-next-line react-hooks/exhaustive-deps
172
+ }, [result, resolveRequest]);
173
+
174
+ const setQuery = useCallback(
175
+ (value: string) => {
176
+ setQueryState(value);
177
+ // Typing in the field is an explicit re-aim: the first match at or after
178
+ // the caret the widget opened on is the one the user means.
179
+ reseed(anchorRef.current);
180
+ },
181
+ [setQueryState, reseed],
182
+ );
183
+
184
+ const openFind = useCallback(() => {
185
+ setFocusRequest((previous) => previous + 1);
186
+ // Already open: ⌘F is a request for the field, not a new search. Re-seeding
187
+ // here would overwrite what the user typed with the current match's text,
188
+ // since the widget keeps that match selected in the document.
189
+ if (openRef.current) return;
190
+ const selection = readSelection();
191
+ const seeded = seedQuery(text, selection);
192
+ if (seeded) setQueryState(seeded);
193
+ reseed(selection ? selection.start : 0);
194
+ setOpen(true);
195
+ // The replace row is NOT collapsed here: ⌥⌘F opens the bar with it already
196
+ // showing, and this function is the path it takes.
197
+ setResolveRequest((previous) => previous + 1);
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ }, [text, readSelection, reseed, setQueryState]);
200
+
201
+ const openReplace = useCallback(() => {
202
+ setReplaceOpen(true);
203
+ openFind();
204
+ }, [openFind]);
205
+
206
+ const toggleReplace = useCallback(() => setReplaceOpen((prev) => !prev), []);
207
+
208
+ const close = useCallback(() => {
209
+ setOpen(false);
210
+ setReplaceOpen(false);
211
+ // Focus returns to the match, which is where the user was looking.
212
+ const match = result.matches[currentIndex];
213
+ if (match) select(match.start, match.end, true);
214
+ }, [result.matches, currentIndex, select]);
215
+
216
+ const step = useCallback(
217
+ (direction: 1 | -1) => {
218
+ const matches = result.matches;
219
+ if (matches.length === 0) return;
220
+ const index = stepMatchIndex(currentIndex, matches.length, direction);
221
+ setCurrentIndex(index);
222
+ anchorRef.current = matches[index].end;
223
+ // Selection is set without stealing focus: the user may be typing in the
224
+ // find field, and the current-match mark is what shows where it landed.
225
+ select(matches[index].start, matches[index].end, false);
226
+ reveal(matches[index].start);
227
+ },
228
+ [result.matches, currentIndex, select, reveal],
229
+ );
230
+
231
+ const toggleOption = useCallback(
232
+ (key: keyof FindOptions) => {
233
+ setOptions({ ...options, [key]: !options[key] });
234
+ reseed(anchorRef.current);
235
+ },
236
+ [options, setOptions, reseed],
237
+ );
238
+
239
+ const replaceCurrent = useCallback(() => {
240
+ const match = result.matches[currentIndex];
241
+ if (!match) return;
242
+ const next = replaceMatch(text, match, query, replacement, options);
243
+ if (!next) return;
244
+ // Focus stays in the widget: "Replace" is a button the user is about to
245
+ // press again, and pulling focus into the document would eat the next ⌘⌥F.
246
+ applyDocument(next.text, next.caret, next.caret);
247
+ reseed(next.caret);
248
+ }, [result.matches, currentIndex, text, query, replacement, options, applyDocument, reseed]);
249
+
250
+ const replaceAll = useCallback(() => {
251
+ const next = replaceAllMatches(text, query, replacement, options);
252
+ if (next === null || next === text) return;
253
+ const caret = Math.min(anchorRef.current, next.length);
254
+ applyDocument(next, caret, caret);
255
+ reseed(caret);
256
+ }, [text, query, replacement, options, applyDocument, reseed]);
257
+
258
+ const runCommand = useCallback(
259
+ (command: EditorCommand) => {
260
+ switch (command) {
261
+ case 'find':
262
+ openFind();
263
+ return;
264
+ case 'replace':
265
+ openReplace();
266
+ return;
267
+ case 'nextMatch':
268
+ step(1);
269
+ return;
270
+ case 'previousMatch':
271
+ step(-1);
272
+ return;
273
+ case 'replaceOne':
274
+ replaceCurrent();
275
+ return;
276
+ case 'replaceAll':
277
+ replaceAll();
278
+ return;
279
+ case 'toggleCaseSensitive':
280
+ toggleOption('matchCase');
281
+ return;
282
+ case 'toggleWholeWord':
283
+ toggleOption('wholeWord');
284
+ return;
285
+ case 'toggleRegex':
286
+ toggleOption('isRegex');
287
+ return;
288
+ case 'toggleWordWrap':
289
+ toggleWordWrap?.();
290
+ return;
291
+ default:
292
+ return;
293
+ }
294
+ },
295
+ [openFind, openReplace, step, replaceCurrent, replaceAll, toggleOption, toggleWordWrap],
296
+ );
297
+
298
+ return {
299
+ open,
300
+ replaceOpen,
301
+ query,
302
+ replacement,
303
+ options,
304
+ matches: result.matches,
305
+ truncated: result.truncated,
306
+ invalid: result.invalid,
307
+ currentIndex,
308
+ setQuery,
309
+ setReplacement,
310
+ openFind,
311
+ openReplace,
312
+ toggleReplace,
313
+ focusRequest,
314
+ close,
315
+ runCommand,
316
+ toggleOption,
317
+ step,
318
+ replaceCurrent,
319
+ replaceAll,
320
+ };
321
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's undo stack.
8
+ *
9
+ * The rule under test is the one that was wrong: the re-seed exists for a value
10
+ * replaced from the OUTSIDE (a file switch, a refresh), but it also fired for
11
+ * the component's own edits — every keystroke pushed a second, identical entry,
12
+ * so the first ⌘Z restored the buffer to the state it was already in and undo
13
+ * looked broken until the second press. `markApplied` is what separates the two,
14
+ * and these assertions are what keeps it separating them.
15
+ *
16
+ * Runs against a real DOM (happy-dom) because it is a Preact hook holding a
17
+ * `<textarea>`; the modules below are imported dynamically, after those globals
18
+ * exist (Preact binds its environment at evaluation time).
19
+ */
20
+
21
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
22
+ import { Window } from 'happy-dom';
23
+ import type { h as PreactH, render as PreactRender } from 'preact';
24
+ import type { act as PreactAct } from 'preact/test-utils';
25
+ import type { EditorHistory, useEditorHistory as UseEditorHistoryHook } from '@/client/hooks/editor/use-editor-history';
26
+
27
+ let useEditorHistory: typeof UseEditorHistoryHook;
28
+ let h: typeof PreactH;
29
+ let render: typeof PreactRender;
30
+ let act: typeof PreactAct;
31
+
32
+ let api: EditorHistory | null = null;
33
+ let container: HTMLElement;
34
+ let textarea: HTMLTextAreaElement;
35
+
36
+ /** The buffer the hook is told is on screen, changed from the outside by a test. */
37
+ let externalValue = 'one';
38
+
39
+ function Harness() {
40
+ api = useEditorHistory({
41
+ value: externalValue,
42
+ inputRef: { current: textarea },
43
+ onValueChange: (next) => {
44
+ textarea.value = next;
45
+ },
46
+ });
47
+ return null;
48
+ }
49
+
50
+ /** Flush the effect that watches `value`, with no timers: it resolves on a render. */
51
+ async function flush() {
52
+ for (let i = 0; i < 3; i++) {
53
+ await act(async () => {
54
+ for (let turn = 0; turn < 4; turn++) await Promise.resolve();
55
+ });
56
+ }
57
+ }
58
+
59
+ function domGlobals(win: Window): Record<string, unknown> {
60
+ return {
61
+ window: win,
62
+ document: win.document,
63
+ navigator: win.navigator,
64
+ Node: win.Node,
65
+ Element: win.Element,
66
+ HTMLElement: win.HTMLElement,
67
+ HTMLTextAreaElement: win.HTMLTextAreaElement,
68
+ };
69
+ }
70
+
71
+ const installed: Record<string, unknown> = {};
72
+ const displaced: Record<string, unknown> = {};
73
+
74
+ beforeAll(async () => {
75
+ const win = new Window({ url: 'http://localhost' });
76
+ for (const [key, value] of Object.entries(domGlobals(win))) {
77
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
78
+ installed[key] = value;
79
+ (globalThis as Record<string, unknown>)[key] = value;
80
+ }
81
+ ({ useEditorHistory } = await import('@/client/hooks/editor/use-editor-history'));
82
+ ({ h, render } = await import('preact'));
83
+ ({ act } = await import('preact/test-utils'));
84
+ });
85
+
86
+ afterAll(() => {
87
+ for (const key of Object.keys(installed)) {
88
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
89
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
90
+ }
91
+ });
92
+
93
+ beforeEach(async () => {
94
+ api = null;
95
+ externalValue = 'one';
96
+ container = document.createElement('div');
97
+ document.body.appendChild(container);
98
+ textarea = document.createElement('textarea');
99
+ textarea.value = 'one';
100
+ container.appendChild(textarea);
101
+ await act(async () => {
102
+ render(h(Harness, {}), container);
103
+ });
104
+ });
105
+
106
+ afterEach(() => {
107
+ render(null, container);
108
+ container.remove();
109
+ });
110
+
111
+ describe('useEditorHistory', () => {
112
+ test('commit writes the buffer, the caret and the caller’s state', async () => {
113
+ await act(async () => {
114
+ api?.commit({ value: 'two', selectionStart: 2, selectionEnd: 2 });
115
+ });
116
+
117
+ expect(textarea.value).toBe('two');
118
+ expect(textarea.selectionStart).toBe(2);
119
+ });
120
+
121
+ test('an external value replacement is recorded, so undo reaches the old file', async () => {
122
+ // A file switch: the hook did not write this, so it becomes a history entry
123
+ // rather than silently replacing the stack.
124
+ externalValue = 'switched';
125
+ await act(async () => {
126
+ render(h(Harness, {}), container);
127
+ });
128
+ await flush();
129
+
130
+ await act(async () => {
131
+ api?.step('undo');
132
+ });
133
+
134
+ expect(textarea.value).toBe('one');
135
+ });
136
+
137
+ test('a value the hook itself wrote is NOT re-recorded', async () => {
138
+ // The bug this guards: an extra entry per keystroke made the first ⌘Z a
139
+ // no-op, because it restored the buffer to what was already on screen.
140
+ await act(async () => {
141
+ api?.commit({ value: 'two', selectionStart: 2, selectionEnd: 2 });
142
+ });
143
+ externalValue = 'two';
144
+ await act(async () => {
145
+ render(h(Harness, {}), container);
146
+ });
147
+ await flush();
148
+
149
+ await act(async () => {
150
+ api?.step('undo');
151
+ });
152
+
153
+ // One press reaches the ORIGINAL buffer; a duplicate entry would have
154
+ // restored 'two' again.
155
+ expect(textarea.value).toBe('one');
156
+ });
157
+
158
+ test('markApplied declares a browser edit as already recorded', async () => {
159
+ await act(async () => {
160
+ api?.markApplied('typed');
161
+ });
162
+ externalValue = 'typed';
163
+ await act(async () => {
164
+ render(h(Harness, {}), container);
165
+ });
166
+ await flush();
167
+
168
+ await act(async () => {
169
+ api?.step('undo');
170
+ });
171
+
172
+ // Nothing was pushed for `typed`, so there is nothing to step back to: the
173
+ // stack still holds only the initial record.
174
+ expect(textarea.value).toBe('one');
175
+ });
176
+
177
+ test('step at the ends of the stack is a no-op', async () => {
178
+ await act(async () => {
179
+ api?.step('undo');
180
+ });
181
+ const afterUndo = textarea.value;
182
+ await act(async () => {
183
+ api?.step('redo');
184
+ });
185
+
186
+ expect(afterUndo).toBe('one');
187
+ expect(textarea.value).toBe('one');
188
+ });
189
+
190
+ test('commitEdit supersedes the top entry, keeping its caret for the undo', async () => {
191
+ // A line command replaces a whole range at once; undo must return the caret
192
+ // to the selection the command replaced, not to where the edit ended.
193
+ textarea.selectionStart = 1;
194
+ textarea.selectionEnd = 3;
195
+ await act(async () => {
196
+ api?.commitEdit({ value: 'one-changed', selectionStart: 11, selectionEnd: 11 });
197
+ });
198
+ await act(async () => {
199
+ api?.step('undo');
200
+ });
201
+
202
+ expect(textarea.value).toBe('one');
203
+ expect(textarea.selectionStart).toBe(1);
204
+ expect(textarea.selectionEnd).toBe(3);
205
+ });
206
+
207
+ test('the exposed buffer is never a render behind', async () => {
208
+ // `handleChange` diffs against this; a value that lagged a keystroke read a
209
+ // one-character edit as a whole-word replacement.
210
+ await act(async () => {
211
+ api?.commit({ value: 'fresh', selectionStart: 5, selectionEnd: 5 });
212
+ });
213
+
214
+ expect(api?.buffer).toBe('fresh');
215
+ });
216
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's undo stack, wired to the DOM node.
8
+ *
9
+ * `history.ts` is the pure bookkeeping (push, merge, step, cap); this is the
10
+ * part that has to touch the `<textarea>`: committing an edit as one entry,
11
+ * restoring a stepped record, and re-seeding the stack when the value is
12
+ * replaced from the OUTSIDE.
13
+ *
14
+ * The re-seed is the subtle one. It exists for a file switch or a refresh, but
15
+ * a naive `[value]` effect also fires for the component's own edits — every
16
+ * keystroke then pushed a second, identical entry, so the first ⌘Z restored the
17
+ * buffer to the state it was already in and undo looked broken until the second
18
+ * press (and the no-op `onValueChange` truncated the redo stack). The `applied`
19
+ * ref is what distinguishes "someone else replaced the value" from "we did".
20
+ */
21
+
22
+ import { useEffect, useRef } from 'preact/hooks';
23
+
24
+ import { createHistory, pushEdit, pushRecord, stepHistory, type History, type HistoryRecord } from '@/shared/lib/code/editor/history';
25
+
26
+ /** The fields every write path needs: a buffer and where the caret goes. */
27
+ export type HistoryEdit = Pick<HistoryRecord, 'value' | 'selectionStart' | 'selectionEnd'>;
28
+
29
+ export interface EditorHistory {
30
+ /** The stack itself, for the typing-burst merge in the change handler. */
31
+ history: History;
32
+ /**
33
+ * The buffer as this component last knew it — its own writes included, and
34
+ * never a render behind. Diffing a browser edit has to start from here: the
35
+ * `value` prop lags a keystroke, and a stale `before` reads a one-character
36
+ * edit as a whole-word replacement.
37
+ */
38
+ readonly buffer: string;
39
+ /** Commit an edit as ONE undoable entry. */
40
+ commit: (edit: HistoryEdit) => void;
41
+ /**
42
+ * Commit an edit that SUPERSEDES the entry on top, keeping that entry's
43
+ * caret so undo returns to the selection the edit replaced.
44
+ */
45
+ commitEdit: (edit: HistoryEdit) => void;
46
+ /** Step the stack and restore the record; no-op at either end. */
47
+ step: (direction: 'undo' | 'redo') => void;
48
+ /**
49
+ * Declare a value the BROWSER produced as already applied, so the re-seed
50
+ * effect below does not record it a second time. The change handler calls
51
+ * this for every keystroke and every replicated multi-cursor edit.
52
+ */
53
+ markApplied: (value: string) => void;
54
+ }
55
+
56
+ export interface UseEditorHistoryOptions {
57
+ /** The controlled buffer, used to detect an external replacement. */
58
+ value: string;
59
+ /** The live `<textarea>`, so an external replacement records its own caret. */
60
+ inputRef: { current: HTMLTextAreaElement | null };
61
+ /** Push the committed buffer to the caller. */
62
+ onValueChange: (value: string) => void;
63
+ }
64
+
65
+ export function useEditorHistory({ value, inputRef, onValueChange }: UseEditorHistoryOptions): EditorHistory {
66
+ const historyRef = useRef<History>(createHistory());
67
+ /** The value this component itself last wrote — see the module note. */
68
+ const appliedRef = useRef<string | null>(null);
69
+
70
+ // Re-record when the controlled value is replaced from the outside
71
+ // (file switch, refresh) so undo does not jump between documents.
72
+ useEffect(() => {
73
+ const input = inputRef.current;
74
+ if (!input || appliedRef.current === value) return;
75
+ appliedRef.current = value;
76
+ pushRecord(historyRef.current, {
77
+ value: input.value,
78
+ selectionStart: input.selectionStart,
79
+ selectionEnd: input.selectionEnd,
80
+ timestamp: Date.now(),
81
+ });
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ }, [value]);
84
+
85
+ /** Put a buffer on screen without touching the stack. */
86
+ const write = (edit: HistoryEdit) => {
87
+ appliedRef.current = edit.value;
88
+ onValueChange(edit.value);
89
+ const input = inputRef.current;
90
+ if (!input) return;
91
+ input.value = edit.value;
92
+ input.selectionStart = edit.selectionStart;
93
+ input.selectionEnd = edit.selectionEnd;
94
+ };
95
+
96
+ return {
97
+ history: historyRef.current,
98
+
99
+ get buffer() {
100
+ return appliedRef.current ?? value;
101
+ },
102
+
103
+ commit: (edit) => {
104
+ pushEdit(historyRef.current, { ...edit, timestamp: Date.now() });
105
+ write(edit);
106
+ },
107
+
108
+ commitEdit: (edit) => {
109
+ const history = historyRef.current;
110
+ const last = history.stack[history.offset];
111
+ const input = inputRef.current;
112
+ if (last && input) {
113
+ // The entry on top is about to be superseded: keep where its caret was,
114
+ // so undo returns to the selection this edit replaced.
115
+ history.stack[history.offset] = {
116
+ ...last,
117
+ selectionStart: input.selectionStart,
118
+ selectionEnd: input.selectionEnd,
119
+ };
120
+ }
121
+ pushEdit(history, { ...edit, timestamp: Date.now() });
122
+ write(edit);
123
+ },
124
+
125
+ step: (direction) => {
126
+ const record = stepHistory(historyRef.current, direction);
127
+ if (record) write(record);
128
+ },
129
+
130
+ markApplied: (next) => {
131
+ appliedRef.current = next;
132
+ },
133
+ };
134
+ }