ompchamber 3.0.0 → 3.1.1
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.
- package/README.md +30 -8
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-5yfre1kw.css +1 -0
- package/dist/client/static/js/{index-prbmx3yj.js → index-qhzsybkn.js} +643 -617
- package/package.json +1 -1
- package/src/cli/lib/args.js +4 -3
- package/src/cli/lib/commands/restart.js +7 -6
- package/src/cli/lib/commands/stop.js +38 -31
- package/src/cli/lib/commands/update.js +3 -2
- package/src/cli/lib/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- package/src/client/components/common/code-editor/handler.test.ts +274 -0
- package/src/client/components/common/code-editor/handler.ts +175 -0
- package/src/client/components/common/code-editor/index.tsx +185 -220
- package/src/client/components/common/code-surface/index.tsx +109 -12
- package/src/client/components/common/lazy-panels.tsx +5 -1
- package/src/client/components/common/viewport.ts +5 -3
- package/src/client/components/layout/RightActivityBar.tsx +2 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
- package/src/client/components/layout/desktop-layout/index.tsx +24 -4
- package/src/client/components/mobile/RightSidebar.tsx +11 -4
- package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
- package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
- package/src/client/components/workspace/browser-panel/index.tsx +6 -6
- package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
- package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
- package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
- package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
- package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
- package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
- package/src/client/components/workspace/editor/index.tsx +153 -53
- package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
- package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
- package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
- package/src/client/components/workspace/todo-panel/index.tsx +161 -0
- package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
- package/src/client/hooks/editor/scroll-container.test.ts +102 -0
- package/src/client/hooks/editor/scroll-container.ts +26 -0
- package/src/client/hooks/editor/use-code-window.ts +2 -20
- package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
- package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
- package/src/client/hooks/editor/use-editor-find.ts +321 -0
- package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
- package/src/client/hooks/editor/use-editor-history.ts +134 -0
- package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
- package/src/client/hooks/editor/use-occurrences.ts +98 -0
- package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
- package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
- package/src/client/hooks/workspace/session-todos.ts +102 -0
- package/src/client/tailwind.css +30 -0
- package/src/server/lib/bundler/css.test.ts +87 -0
- package/src/server/lib/bundler/css.ts +17 -2
- package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
- package/src/server/lib/bundler/nested-at-rules.ts +146 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -0
- package/src/server/lib/lifecycle/restart.ts +9 -14
- package/src/server/lib/omp/session/todos.ts +100 -0
- package/src/server/routes/fs/action-create.test.ts +90 -0
- package/src/server/routes/fs/action.ts +23 -0
- package/src/server/routes/omp/index.ts +2 -0
- package/src/server/routes/omp/session-todos.ts +80 -0
- package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
- package/src/shared/lib/chat/todo/progress.ts +97 -0
- package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
- package/src/shared/lib/chat/todo/snapshot.ts +215 -0
- package/src/shared/lib/code/editor/commands.test.ts +155 -0
- package/src/shared/lib/code/editor/commands.ts +260 -0
- package/src/shared/lib/code/editor/edits.test.ts +100 -0
- package/src/shared/lib/code/editor/edits.ts +122 -0
- package/src/shared/lib/code/editor/find-status.test.ts +50 -0
- package/src/shared/lib/code/editor/find-status.ts +36 -0
- package/src/shared/lib/code/editor/find.test.ts +203 -0
- package/src/shared/lib/code/editor/find.ts +224 -0
- package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
- package/src/shared/lib/code/editor/keymap.test.ts +109 -0
- package/src/shared/lib/code/editor/keymap.ts +151 -0
- package/src/shared/lib/code/editor/layers.test.ts +77 -0
- package/src/shared/lib/code/editor/layers.ts +67 -0
- package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
- package/src/shared/lib/code/editor/line-edits.ts +159 -0
- package/src/shared/lib/code/editor/lines.test.ts +81 -0
- package/src/shared/lib/code/editor/lines.ts +83 -0
- package/src/shared/lib/code/editor/marks.test.ts +86 -0
- package/src/shared/lib/code/editor/marks.ts +106 -0
- package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
- package/src/shared/lib/code/editor/occurrences.ts +111 -0
- package/src/shared/lib/code/editor/palette.test.ts +91 -0
- package/src/shared/lib/code/editor/palette.ts +92 -0
- package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
- package/src/shared/lib/code/editor/reset-css.ts +32 -0
- package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
- package/src/shared/lib/code/editor/text-commands.ts +154 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
- package/src/shared/lib/code/syntax-highlight.ts +29 -17
- package/src/shared/lib/code/windowed-highlight.ts +52 -1
- package/src/shared/lib/ui/key-binding.test.ts +115 -0
- package/src/shared/lib/ui/key-binding.ts +143 -0
- package/src/shared/lib/util/platform.test.ts +87 -0
- package/src/shared/lib/util/platform.ts +30 -0
- package/src/shared/lib/workspace/keymap.test.ts +82 -0
- package/src/shared/lib/workspace/keymap.ts +30 -0
- package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
- package/src/shared/lib/workspace/right-panels.ts +8 -0
- package/src/shared/lib/workspace/session-state/store.ts +0 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/todo.ts +108 -0
- package/dist/client/static/js/index-ranztes9.css +0 -1
- package/src/server/lib/lifecycle/restart.test.ts +0 -21
- /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
|
+
}
|