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.
Files changed (114) hide show
  1. package/README.md +30 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-5yfre1kw.css +1 -0
  5. package/dist/client/static/js/{index-prbmx3yj.js → index-qhzsybkn.js} +643 -617
  6. package/package.json +1 -1
  7. package/src/cli/lib/args.js +4 -3
  8. package/src/cli/lib/commands/restart.js +7 -6
  9. package/src/cli/lib/commands/stop.js +38 -31
  10. package/src/cli/lib/commands/update.js +3 -2
  11. package/src/cli/lib/stop-scope.js +23 -0
  12. package/src/cli/lib/stop-scope.test.js +41 -0
  13. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  14. package/src/client/components/common/code-editor/handler.ts +175 -0
  15. package/src/client/components/common/code-editor/index.tsx +185 -220
  16. package/src/client/components/common/code-surface/index.tsx +109 -12
  17. package/src/client/components/common/lazy-panels.tsx +5 -1
  18. package/src/client/components/common/viewport.ts +5 -3
  19. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  20. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  21. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  22. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  23. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  24. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  25. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  26. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  27. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  28. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  29. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  30. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  31. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  32. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  33. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  34. package/src/client/components/workspace/editor/index.tsx +153 -53
  35. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  36. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  37. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  38. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  39. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  40. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  41. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  42. package/src/client/hooks/editor/scroll-container.ts +26 -0
  43. package/src/client/hooks/editor/use-code-window.ts +2 -20
  44. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  45. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  46. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  47. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  48. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  49. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  50. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  51. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  52. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  53. package/src/client/hooks/workspace/session-todos.ts +102 -0
  54. package/src/client/tailwind.css +30 -0
  55. package/src/server/lib/bundler/css.test.ts +87 -0
  56. package/src/server/lib/bundler/css.ts +17 -2
  57. package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
  58. package/src/server/lib/bundler/nested-at-rules.ts +146 -0
  59. package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
  60. package/src/server/lib/lifecycle/launch-mode.ts +26 -0
  61. package/src/server/lib/lifecycle/restart.ts +9 -14
  62. package/src/server/lib/omp/session/todos.ts +100 -0
  63. package/src/server/routes/fs/action-create.test.ts +90 -0
  64. package/src/server/routes/fs/action.ts +23 -0
  65. package/src/server/routes/omp/index.ts +2 -0
  66. package/src/server/routes/omp/session-todos.ts +80 -0
  67. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  68. package/src/shared/lib/chat/todo/progress.ts +97 -0
  69. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  70. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  71. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  72. package/src/shared/lib/code/editor/commands.ts +260 -0
  73. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  74. package/src/shared/lib/code/editor/edits.ts +122 -0
  75. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  76. package/src/shared/lib/code/editor/find-status.ts +36 -0
  77. package/src/shared/lib/code/editor/find.test.ts +203 -0
  78. package/src/shared/lib/code/editor/find.ts +224 -0
  79. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  80. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  81. package/src/shared/lib/code/editor/keymap.ts +151 -0
  82. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  83. package/src/shared/lib/code/editor/layers.ts +67 -0
  84. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  85. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  86. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  87. package/src/shared/lib/code/editor/lines.ts +83 -0
  88. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  89. package/src/shared/lib/code/editor/marks.ts +106 -0
  90. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  91. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  92. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  93. package/src/shared/lib/code/editor/palette.ts +92 -0
  94. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  95. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  96. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  97. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  98. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  99. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  100. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  101. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  102. package/src/shared/lib/ui/key-binding.ts +143 -0
  103. package/src/shared/lib/util/platform.test.ts +87 -0
  104. package/src/shared/lib/util/platform.ts +30 -0
  105. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  106. package/src/shared/lib/workspace/keymap.ts +30 -0
  107. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  108. package/src/shared/lib/workspace/right-panels.ts +8 -0
  109. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/todo.ts +108 -0
  112. package/dist/client/static/js/index-ranztes9.css +0 -1
  113. package/src/server/lib/lifecycle/restart.test.ts +0 -21
  114. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's keydown handler.
8
+ *
9
+ * This is dispatch, not rendering: it reads the textarea, resolves the event
10
+ * against the keymap, and routes the command to whoever owns it — the undo
11
+ * stack, the multi-selection, the pure text commands, or the panel (the find
12
+ * bar's chords, word wrap). It lives outside the component because the
13
+ * component is at the repository's line ceiling and because this is the one
14
+ * place that must agree with the keymap about what each key means.
15
+ *
16
+ * The rules that are NOT obvious, each of which was a bug in the naive version:
17
+ *
18
+ * - **Escape is not a plain blur.** With extra ⌘D ranges live, its first job is
19
+ * to drop them; only the next press hands the keyboard back to the page.
20
+ * - **Tab / Backspace / Enter are not keymap entries.** Their behaviour is
21
+ * about the DOM node (indentation, focus, the Tab-capture toggle) as much as
22
+ * the buffer, so they are handled before the keymap is consulted.
23
+ * - **A command the editor does not own is not consumed** unless the panel
24
+ * accepts it, so ⌘F still reaches the panel's capture handler and the
25
+ * browser's own find bar never opens by accident.
26
+ */
27
+
28
+ import type { TargetedKeyboardEvent } from 'preact';
29
+
30
+ import { editForBackspace, editForEnter, editForTab } from '@/shared/lib/code/editor/edits';
31
+ import { isTextCommand, runTextCommand, type CommittedEdit } from '@/shared/lib/code/editor/text-commands';
32
+ import type { OccurrencesApi } from '@/client/hooks/editor/use-occurrences';
33
+ import type { EditorHistory } from '@/client/hooks/editor/use-editor-history';
34
+ import { EDITOR_KEY_BINDINGS, isFindBarCommand, type EditorCommand } from '@/shared/lib/code/editor/keymap';
35
+ import { resolveBinding } from '@/shared/lib/ui/key-binding';
36
+ import { isMacPlatform } from '@/shared/lib/util/platform';
37
+
38
+ export interface EditorKeydownDeps {
39
+ /** The live `<textarea>`, which owns the buffer and the primary selection. */
40
+ input: HTMLTextAreaElement;
41
+ /** Shiki language id, for the comment syntax. */
42
+ language: string;
43
+ /** Whether Tab is captured (Ctrl+M toggles it). */
44
+ captureTab: boolean;
45
+ toggleCaptureTab: () => void;
46
+ history: EditorHistory;
47
+ multi: OccurrencesApi;
48
+ /** Select `[start, end)` and focus the field. */
49
+ select: (start: number, end: number) => void;
50
+ /** Hand a command the editor does not own to the panel. */
51
+ delegate: (command: EditorCommand) => void;
52
+ }
53
+
54
+ /**
55
+ * Run a command that is already resolved — the routing half of the handler,
56
+ * shared by the keydown path and by callers that have no event at all (the
57
+ * command palette, a toolbar button). Splitting it out is what stops the
58
+ * palette from being a second, partial dispatcher: a command reachable from
59
+ * the keyboard is reachable from the palette by construction.
60
+ */
61
+ export function runEditorCommand(command: EditorCommand, deps: EditorKeydownDeps, extend = false): void {
62
+ const { input, history, multi, select } = deps;
63
+ const { value: text, selectionStart, selectionEnd } = input;
64
+
65
+ if (command === 'undo' || command === 'redo') {
66
+ multi.clear();
67
+ history.step(command === 'undo' ? 'undo' : 'redo');
68
+ return;
69
+ }
70
+ if (command === 'selectNextOccurrence' || command === 'selectAllOccurrences') {
71
+ if (command === 'selectNextOccurrence') multi.selectNext(input, select);
72
+ else multi.selectAll(input, select);
73
+ return;
74
+ }
75
+ if (command === 'insertCursorBelow') {
76
+ multi.insertCursorBelow(input);
77
+ return;
78
+ }
79
+
80
+ if (isTextCommand(command)) {
81
+ runTextCommand(command, {
82
+ value: text,
83
+ selectionStart,
84
+ selectionEnd,
85
+ extend,
86
+ language: deps.language,
87
+ commit: (edit: CommittedEdit) => {
88
+ // A line command replaces the whole selection model: the extra ranges
89
+ // pointed at text that has just moved, and keeping them would leave
90
+ // carets on the wrong lines.
91
+ multi.clear();
92
+ history.commitEdit(edit);
93
+ },
94
+ setCaret: (offset) => select(offset, offset),
95
+ selectRange: (start, end) => select(start, end),
96
+ setDocumentEdge: (edge) => {
97
+ const offset = edge === 'start' ? 0 : text.length;
98
+ select(offset, offset);
99
+ },
100
+ });
101
+ return;
102
+ }
103
+
104
+ deps.delegate(command);
105
+ }
106
+
107
+ /** Run the keydown handler. Every branch returns, so exactly one action fires. */
108
+ export function handleEditorKeydown(e: TargetedKeyboardEvent<HTMLTextAreaElement>, deps: EditorKeydownDeps): void {
109
+ const { input, history, multi } = deps;
110
+ const { value: text, selectionStart, selectionEnd } = input;
111
+ const isMac = isMacPlatform();
112
+
113
+ if (e.key === 'Escape') {
114
+ if (multi.ranges.length > 0) {
115
+ e.preventDefault();
116
+ multi.clear();
117
+ return;
118
+ }
119
+ input.blur();
120
+ return;
121
+ }
122
+
123
+ // The bare-key handling below runs only for an UNMODIFIED key: ⌘⏎ and ⌘[
124
+ // are chords in the keymap, and catching them here first is what made the
125
+ // insert-line and indent commands unreachable (the plain-Enter branch
126
+ // swallowed them and did nothing, because the line had no indentation).
127
+ const unmodified = !e.metaKey && !e.ctrlKey && !e.altKey;
128
+
129
+ if (e.key === 'Tab' && deps.captureTab && unmodified) {
130
+ e.preventDefault();
131
+ // ⇧Tab with nothing selected is not an unindent — `editForTab` returns the
132
+ // buffer unchanged, and the key is consumed so focus cannot escape the
133
+ // editor mid-line.
134
+ const edit = editForTab(text, selectionStart, selectionEnd, e.shiftKey);
135
+ if (edit.value !== text) history.commitEdit(edit);
136
+ return;
137
+ }
138
+ if (e.key === 'Backspace' && unmodified) {
139
+ const edit = editForBackspace(text, selectionStart, selectionEnd);
140
+ if (edit) {
141
+ e.preventDefault();
142
+ history.commitEdit(edit);
143
+ }
144
+ return;
145
+ }
146
+ if (e.key === 'Enter' && unmodified) {
147
+ const edit = editForEnter(text, selectionStart, selectionEnd);
148
+ if (edit) {
149
+ e.preventDefault();
150
+ history.commitEdit(edit);
151
+ }
152
+ return;
153
+ }
154
+
155
+ const command = resolveBinding(EDITOR_KEY_BINDINGS, e);
156
+ if (!command) {
157
+ // Ctrl+M / Ctrl+Shift+M toggles Tab capture so keyboard users can leave.
158
+ if (e.ctrlKey && e.key.toLowerCase() === 'm' && (isMac ? e.shiftKey : true)) {
159
+ e.preventDefault();
160
+ deps.toggleCaptureTab();
161
+ }
162
+ return;
163
+ }
164
+
165
+ // The editor owns everything except the panel's own commands, which the
166
+ // delegate reports on. A find-bar chord is consumed either way, so the
167
+ // browser's find bar never opens over the editor.
168
+ e.preventDefault();
169
+ if (isFindBarCommand(command)) {
170
+ deps.delegate(command);
171
+ return;
172
+ }
173
+ runEditorCommand(command, deps, e.shiftKey);
174
+
175
+ }
@@ -1,15 +1,14 @@
1
- import { useEffect, useRef, useState } from 'preact/hooks';
1
+ import { useImperativeHandle, useLayoutEffect, useRef, useState } from 'preact/hooks';
2
2
  import type { CSSProperties, Ref, TargetedKeyboardEvent } from 'preact';
3
3
 
4
- import {
5
- continuesTyping,
6
- createHistory,
7
- mergeTyping,
8
- pushEdit,
9
- pushRecord,
10
- stepHistory,
11
- type HistoryRecord,
12
- } from '@/client/components/common/code-editor/history';
4
+ import { continuesTyping, mergeTyping, pushRecord } from '@/shared/lib/code/editor/history';
5
+ import { handleEditorKeydown, runEditorCommand, type EditorKeydownDeps } from '@/client/components/common/code-editor/handler';
6
+ import { layerStyle, layerStyles } from '@/shared/lib/code/editor/layers';
7
+ import { CODE_EDITOR_RESET_CSS } from '@/shared/lib/code/editor/reset-css';
8
+ import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
9
+ import { useOccurrences } from '@/client/hooks/editor/use-occurrences';
10
+ import { useEditorHistory } from '@/client/hooks/editor/use-editor-history';
11
+ import type { TextRange } from '@/shared/lib/code/editor/commands';
13
12
  import type { CodeWindow, LineWindow } from '@/shared/lib/code/lazy-window';
14
13
 
15
14
  /**
@@ -27,6 +26,30 @@ import type { CodeWindow, LineWindow } from '@/shared/lib/code/lazy-window';
27
26
  * rest as padding — the alignment that keeps the caret over its own row.
28
27
  */
29
28
 
29
+ export interface CodeEditorHandle {
30
+ /** The `<textarea>` that owns the document — measurements and focus anchor on it. */
31
+ textarea(): HTMLTextAreaElement | null;
32
+ /** Current selection offsets, or null before the field is mounted. */
33
+ getSelection(): { start: number; end: number } | null;
34
+ /** Select a range; `focus` also moves the keyboard focus into the editor. */
35
+ select(start: number, end: number, focus?: boolean): void;
36
+ /**
37
+ * Replace the whole buffer as ONE undoable edit and leave the caret at
38
+ * `caretStart`/`caretEnd`, WITHOUT moving focus. Used by the find widget's
39
+ * replace, which must not look like a burst of typing to the history (⌘Z has
40
+ * to undo it in one step) and must not steal focus from its own field.
41
+ */
42
+ applyDocument(value: string, caretStart: number, caretEnd: number): void;
43
+ /** Every range a ⌘D workflow has selected, primary included — the surface paints them. */
44
+ occurrences(): readonly TextRange[];
45
+ /**
46
+ * Run an editor command by name — the palette's path into the editor. It goes
47
+ * through the same dispatcher the keyboard uses, so a command cannot work
48
+ * from a chord and do nothing from the palette.
49
+ */
50
+ run(command: EditorCommand): void;
51
+ }
52
+
30
53
  export interface CodeEditorProps {
31
54
  value: string;
32
55
  onValueChange: (value: string) => void;
@@ -47,29 +70,28 @@ export interface CodeEditorProps {
47
70
  autoFocus?: boolean;
48
71
  /** Renders only this window of lines, with the rest reserved as space. Omit to mirror the whole value. */
49
72
  virtual?: CodeWindow | null;
73
+ /** Imperative surface for the find widget (selection, reveal, one-shot replace). */
74
+ handleRef?: Ref<CodeEditorHandle>;
75
+ /**
76
+ * Called when the extra selection ranges change, so the surface can paint
77
+ * them. The textarea holds only the primary selection; everything ⌘D adds
78
+ * lives here.
79
+ */
80
+ onOccurrencesChange?: (ranges: readonly TextRange[]) => void;
81
+ /** Commands this editor does not own (find bar chords, word wrap, save). */
82
+ onCommand?: (command: EditorCommand) => void;
83
+ /**
84
+ * The extra ⌘D ranges, held by the CALLER. The editor keeps its own copy in a
85
+ * ref for synchronous reads; this prop is what makes the surface a controlled
86
+ * view of them, so the paint and the editing model cannot disagree.
87
+ */
88
+ occurrences?: readonly TextRange[];
89
+ /** Shiki language id, for the comment syntax. */
90
+ language?: string;
50
91
  }
51
92
 
52
93
  const BASE_TEXTAREA_CLASS = 'code-editor-native-textarea';
53
94
 
54
- const RESET_CSS = `
55
- .code-editor-native-textarea {
56
- -webkit-text-fill-color: transparent;
57
- }
58
- .code-editor-native-textarea::placeholder {
59
- -webkit-text-fill-color: var(--theme-ink);
60
- opacity: 0.45;
61
- }
62
- `;
63
-
64
- /** Shared <style> injected once per page. */
65
- function EditorResetStyle() {
66
- return <style dangerouslySetInnerHTML={{ __html: RESET_CSS }} />;
67
- }
68
-
69
- function getLines(text: string, position: number): string[] {
70
- return text.substring(0, position).split('\n');
71
- }
72
-
73
95
  export function CodeEditor({
74
96
  value,
75
97
  onValueChange,
@@ -84,175 +106,147 @@ export function CodeEditor({
84
106
  readOnly,
85
107
  autoFocus,
86
108
  virtual,
109
+ handleRef,
110
+ occurrences,
111
+ onOccurrencesChange,
112
+ onCommand,
113
+ language = 'javascript',
87
114
  }: CodeEditorProps) {
88
115
  const inputRef = useRef<HTMLTextAreaElement | null>(null);
89
- const historyRef = useRef(createHistory());
90
116
  const [captureTab, setCaptureTab] = useState(true);
117
+ const multi = useOccurrences({
118
+ controlled: occurrences,
119
+ onChange: (ranges) => onOccurrencesChange?.(ranges),
120
+ });
91
121
 
92
- // Re-record when the controlled value is replaced from the outside
93
- // (file switch, refresh) so undo does not jump between documents.
94
- useEffect(() => {
95
- const input = inputRef.current;
96
- if (!input) return;
97
- pushRecord(historyRef.current, {
98
- value: input.value,
99
- selectionStart: input.selectionStart,
100
- selectionEnd: input.selectionEnd,
101
- timestamp: Date.now(),
102
- });
103
- // eslint-disable-next-line react-hooks/exhaustive-deps
104
- }, [value]);
122
+ // A selection asked for before the field exists (the find widget opens and
123
+ // the panel re-renders in the same tick) is applied as soon as it mounts.
124
+ const pendingSelectionRef = useRef<{ start: number; end: number; focus: boolean } | null>(null);
105
125
 
106
- const applyEdits = (record: Pick<HistoryRecord, 'value' | 'selectionStart' | 'selectionEnd'>) => {
107
- const input = inputRef.current;
108
- const last = historyRef.current.stack[historyRef.current.offset];
109
- if (last && input) {
110
- // The entry on top is about to be superseded: keep where its caret was,
111
- // so undo returns to the selection this edit replaced.
112
- historyRef.current.stack[historyRef.current.offset] = {
113
- ...last,
114
- selectionStart: input.selectionStart,
115
- selectionEnd: input.selectionEnd,
116
- };
117
- }
118
- pushEdit(historyRef.current, { ...record, timestamp: Date.now() });
119
- onValueChange(record.value);
120
- if (input) {
121
- input.value = record.value;
122
- input.selectionStart = record.selectionStart;
123
- input.selectionEnd = record.selectionEnd;
124
- }
125
- };
126
-
127
- const handleKeyDown = (e: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
128
- if (e.key === 'Escape') {
129
- e.currentTarget.blur();
130
- return;
131
- }
132
- const { value: text, selectionStart, selectionEnd } = e.currentTarget;
133
- const tabCharacter = ' ';
134
- const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
135
- const isWindows = /Win/i.test(navigator.platform);
126
+ /**
127
+ * The buffer and selection as of the last event that could precede an edit.
128
+ *
129
+ * `handleChange` has to know which range the BROWSER replaced, and the `input`
130
+ * event reports the state AFTER the edit — useless for locating it. Keydown,
131
+ * select and the component's own writes all land before the edit, so
132
+ * recording there is what makes the diff in `handleChange` unambiguous.
133
+ *
134
+ * The buffer that goes with it comes from the history hook (`undoStack.buffer`),
135
+ * NOT from the `value` prop: the prop lags a keystroke — it only moves once
136
+ * the panel's state has re-rendered, and the next keystroke can arrive first
137
+ * (measured: typing at several carets with a 30 ms gap). A stale `before`
138
+ * turns the diff into a replacement of the whole word, which is what made a
139
+ * five-letter burst take five undos.
140
+ */
141
+ const lastSelectionRef = useRef<TextRange>({ start: 0, end: 0 });
136
142
 
137
- if (e.key === 'Tab' && captureTab) {
138
- e.preventDefault();
139
- if (e.shiftKey) {
140
- // Unindent selected lines
141
- const linesBeforeCaret = getLines(text, selectionStart);
142
- const startLine = linesBeforeCaret.length - 1;
143
- const endLine = getLines(text, selectionEnd).length - 1;
144
- const nextValue = text
145
- .split('\n')
146
- .map((line, i) => (i >= startLine && i <= endLine && line.startsWith(tabCharacter) ? line.substring(tabCharacter.length) : line))
147
- .join('\n');
148
- if (nextValue !== text) {
149
- const startLineText = linesBeforeCaret[startLine];
150
- applyEdits({
151
- value: nextValue,
152
- selectionStart: startLineText?.startsWith(tabCharacter) ? selectionStart - tabCharacter.length : selectionStart,
153
- selectionEnd: selectionEnd - (text.length - nextValue.length),
154
- });
155
- }
156
- } else if (selectionStart !== selectionEnd) {
157
- // Indent selected lines
158
- const linesBeforeCaret = getLines(text, selectionStart);
159
- const startLine = linesBeforeCaret.length - 1;
160
- const endLine = getLines(text, selectionEnd).length - 1;
161
- const startLineText = linesBeforeCaret[startLine];
162
- applyEdits({
163
- value: text
164
- .split('\n')
165
- .map((line, i) => (i >= startLine && i <= endLine ? tabCharacter + line : line))
166
- .join('\n'),
167
- selectionStart: startLineText && /\S/.test(startLineText) ? selectionStart + tabCharacter.length : selectionStart,
168
- selectionEnd: selectionEnd + tabCharacter.length * (endLine - startLine + 1),
169
- });
170
- } else {
171
- const updatedSelection = selectionStart + tabCharacter.length;
172
- applyEdits({
173
- value: text.substring(0, selectionStart) + tabCharacter + text.substring(selectionEnd),
174
- selectionStart: updatedSelection,
175
- selectionEnd: updatedSelection,
176
- });
177
- }
143
+ const setSelection = (start: number, end: number, focus: boolean) => {
144
+ lastSelectionRef.current = { start, end };
145
+ const input = inputRef.current;
146
+ if (!input) {
147
+ pendingSelectionRef.current = { start, end, focus };
178
148
  return;
179
149
  }
150
+ input.selectionStart = start;
151
+ input.selectionEnd = end;
152
+ if (focus) input.focus();
153
+ };
180
154
 
181
- if (e.key === 'Backspace' && selectionStart === selectionEnd) {
182
- const textBeforeCaret = text.substring(0, selectionStart);
183
- if (textBeforeCaret.endsWith(tabCharacter)) {
184
- e.preventDefault();
185
- const updatedSelection = selectionStart - tabCharacter.length;
186
- applyEdits({
187
- value: text.substring(0, selectionStart - tabCharacter.length) + text.substring(selectionEnd),
188
- selectionStart: updatedSelection,
189
- selectionEnd: updatedSelection,
190
- });
191
- }
192
- return;
193
- }
155
+ useImperativeHandle(
156
+ handleRef ?? null,
157
+ () => ({
158
+ textarea: () => inputRef.current,
159
+ getSelection: () => {
160
+ const input = inputRef.current;
161
+ return input ? { start: input.selectionStart, end: input.selectionEnd } : null;
162
+ },
163
+ select: setSelection,
164
+ applyDocument: (next, caretStart, caretEnd) => {
165
+ undoStack.commitEdit({ value: next, selectionStart: caretStart, selectionEnd: caretEnd });
166
+ // Focus is deliberately left where it was: the find widget's Replace
167
+ // button is about to be pressed again, and pulling focus into the
168
+ // document would eat the next keystroke.
169
+ setSelection(caretStart, caretEnd, false);
170
+ },
171
+ occurrences: () => multi.ranges,
172
+ run: (command) => {
173
+ const input = inputRef.current;
174
+ if (input) runEditorCommand(command, keydownDeps(input));
175
+ },
176
+ }),
177
+ // `applyEdits` closes over `onValueChange` and the history ref only, both of
178
+ // which are stable for the component's life.
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ [onValueChange],
181
+ );
194
182
 
195
- if (e.key === 'Enter' && selectionStart === selectionEnd) {
196
- // Preserve indentation on new lines
197
- const line = getLines(text, selectionStart).pop();
198
- const matches = line?.match(/^\s+/);
199
- if (matches?.[0]) {
200
- e.preventDefault();
201
- const indent = '\n' + matches[0];
202
- const updatedSelection = selectionStart + indent.length;
203
- applyEdits({
204
- value: text.substring(0, selectionStart) + indent + text.substring(selectionEnd),
205
- selectionStart: updatedSelection,
206
- selectionEnd: updatedSelection,
207
- });
208
- }
209
- return;
210
- }
183
+ // Runs on every render: a selection asked for while the field was not yet
184
+ // mounted (the find widget opens in the same tick as the panel's first
185
+ // render) has to land as soon as the textarea exists.
186
+ useLayoutEffect(() => {
187
+ const pending = pendingSelectionRef.current;
188
+ if (!pending || !inputRef.current) return;
189
+ pendingSelectionRef.current = null;
190
+ setSelection(pending.start, pending.end, pending.focus);
191
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
+ });
211
193
 
212
- // Undo (⌘Z / Ctrl+Z) through the custom history: browser undo would
213
- // desync the textarea from the highlighted <pre>.
214
- const undoKey = isMac ? e.metaKey && e.key.toLowerCase() === 'z' : e.ctrlKey && e.key.toLowerCase() === 'z';
215
- if (undoKey && !e.shiftKey && !e.altKey) {
216
- e.preventDefault();
217
- const record = stepHistory(historyRef.current, 'undo');
218
- if (record && inputRef.current) {
219
- inputRef.current.value = record.value;
220
- inputRef.current.selectionStart = record.selectionStart;
221
- inputRef.current.selectionEnd = record.selectionEnd;
222
- onValueChange(record.value);
223
- }
224
- return;
225
- }
194
+ const undoStack = useEditorHistory({ value, inputRef, onValueChange });
226
195
 
227
- const redoKey = isMac
228
- ? e.metaKey && e.shiftKey && e.key.toLowerCase() === 'z'
229
- : isWindows
230
- ? e.ctrlKey && e.key.toLowerCase() === 'y'
231
- : e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'z';
232
- if (redoKey && !e.altKey) {
233
- e.preventDefault();
234
- const record = stepHistory(historyRef.current, 'redo');
235
- if (record && inputRef.current) {
236
- inputRef.current.value = record.value;
237
- inputRef.current.selectionStart = record.selectionStart;
238
- inputRef.current.selectionEnd = record.selectionEnd;
239
- onValueChange(record.value);
240
- }
241
- return;
242
- }
196
+ /** Everything the dispatcher needs, built once per call from the live refs. */
197
+ const keydownDeps = (input: HTMLTextAreaElement): EditorKeydownDeps => ({
198
+ input,
199
+ language,
200
+ captureTab,
201
+ toggleCaptureTab: () => setCaptureTab((prev) => !prev),
202
+ history: undoStack,
203
+ multi,
204
+ select: (start, end) => setSelection(start, end, true),
205
+ delegate: (command) => {
206
+ onCommand?.(command);
207
+ },
208
+ });
243
209
 
244
- // Ctrl+M / Ctrl+Shift+M toggles Tab capture so keyboard users can leave.
245
- if (e.ctrlKey && e.key.toLowerCase() === 'm' && (isMac ? e.shiftKey : true)) {
246
- e.preventDefault();
247
- setCaptureTab((prev) => !prev);
248
- }
210
+ const handleKeyDown = (e: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
211
+ // The selection as of the last event that could precede an edit:
212
+ // `handleChange` needs to know which range the browser replaced, and the
213
+ // `input` event reports the selection AFTER the edit.
214
+ lastSelectionRef.current = { start: e.currentTarget.selectionStart, end: e.currentTarget.selectionEnd };
215
+ handleEditorKeydown(e, keydownDeps(e.currentTarget));
249
216
  };
250
217
 
251
218
  const handleChange = (e: Event) => {
252
219
  const input = e.target as HTMLTextAreaElement;
253
220
  const { value: next, selectionStart, selectionEnd } = input;
254
- const history = historyRef.current;
221
+ const history = undoStack.history;
255
222
  const timestamp = Date.now();
223
+ // With extra ranges live, the browser's edit becomes a template: it is
224
+ // diffed out of the OLD buffer and replayed at every other range in one
225
+ // pass, so a keystroke, a paste and an IME composition all need no special
226
+ // case. The result is committed as one history entry, and the ranges
227
+ // collapse to carets so a second keystroke continues at all of them.
228
+ const replicated = multi.replicate(undoStack.buffer, next, lastSelectionRef.current);
229
+ if (replicated) {
230
+ undoStack.markApplied(replicated.value);
231
+ input.value = replicated.value;
232
+ input.selectionStart = replicated.caret;
233
+ input.selectionEnd = replicated.caret;
234
+ lastSelectionRef.current = { start: replicated.caret, end: replicated.caret };
235
+ multi.set(replicated.rest);
236
+ // The burst merge applies here too: without it each character of a word
237
+ // typed at several carets became its own undo entry, so ⌘Z peeled off one
238
+ // letter instead of the word — the exact behaviour the single-caret path
239
+ // had already been fixed for.
240
+ if (continuesTyping(history.stack[history.offset], replicated.value, replicated.caret, timestamp)) {
241
+ mergeTyping(history, replicated.value, replicated.caret, replicated.caret, timestamp);
242
+ } else {
243
+ pushRecord(history, { value: replicated.value, selectionStart: replicated.caret, selectionEnd: replicated.caret, timestamp });
244
+ }
245
+ onValueChange(replicated.value);
246
+ return;
247
+ }
248
+
249
+ undoStack.markApplied(next);
256
250
  // Merge typing bursts into one word-level undo entry.
257
251
  if (continuesTyping(history.stack[history.offset], next, selectionStart, timestamp)) {
258
252
  mergeTyping(history, next, selectionStart, selectionEnd, timestamp);
@@ -263,44 +257,8 @@ export function CodeEditor({
263
257
  onValueChange(next);
264
258
  };
265
259
 
266
- const layerStyle: CSSProperties = {
267
- margin: 0,
268
- border: 0,
269
- background: 'none',
270
- boxSizing: 'inherit',
271
- display: 'inherit',
272
- fontFamily: 'inherit',
273
- fontSize: 'inherit',
274
- fontStyle: 'inherit',
275
- fontVariantLigatures: 'inherit',
276
- fontWeight: 'inherit',
277
- letterSpacing: 'inherit',
278
- lineHeight: 'inherit',
279
- tabSize: 'inherit',
280
- textIndent: 'inherit',
281
- textRendering: 'inherit',
282
- textTransform: 'inherit',
283
- whiteSpace: 'pre-wrap',
284
- wordBreak: 'keep-all',
285
- overflowWrap: 'break-word',
286
- };
287
-
288
- // The textarea owns the full document, so its padding is the plain inset and
289
- // its lines land exactly where the geometry says line 0 starts.
290
- const contentStyle: CSSProperties = {
291
- paddingTop: padding,
292
- paddingRight: padding,
293
- paddingBottom: padding,
294
- paddingLeft: padding,
295
- };
296
-
297
- // The `<pre>` carries only the window; the lines it is not given are reserved
298
- // as padding, so the aligned layers stay aligned.
299
- const windowStyle: CSSProperties = {
300
- ...contentStyle,
301
- paddingTop: padding + (virtual?.top ?? 0),
302
- paddingBottom: padding + (virtual?.bottom ?? 0),
303
- };
260
+ const { pre: preStyle, textarea: textareaStyle } = layerStyles(padding, virtual);
261
+ const layer = layerStyle();
304
262
 
305
263
  const highlighted = highlight(value, virtual ?? null);
306
264
 
@@ -310,16 +268,21 @@ export function CodeEditor({
310
268
  ref={preRef}
311
269
  className={preClassName}
312
270
  aria-hidden="true"
313
- style={{ ...layerStyle, ...windowStyle, position: 'relative', pointerEvents: 'none' }}
271
+ style={{ ...layer, ...preStyle, position: 'relative', pointerEvents: 'none' }}
314
272
  dangerouslySetInnerHTML={{ __html: highlighted + (virtual && !virtual.breakAtEnd ? '' : '<br />') }}
315
273
  />
316
274
  <textarea
317
275
  ref={inputRef}
318
276
  className={`${BASE_TEXTAREA_CLASS}${textareaClassName ? ` ${textareaClassName}` : ''}`}
319
- style={{ ...layerStyle, ...contentStyle, position: 'absolute', top: 0, left: 0, height: '100%', width: '100%', resize: 'none', color: 'inherit', overflow: 'hidden', WebkitTextFillColor: 'transparent' }}
277
+ style={{ ...layer, ...textareaStyle, position: 'absolute', top: 0, left: 0, height: '100%', width: '100%', resize: 'none', color: 'inherit', overflow: 'hidden', WebkitTextFillColor: 'transparent' }}
320
278
  value={value}
321
279
  onChange={handleChange}
322
280
  onKeyDown={handleKeyDown}
281
+ onSelect={() => {
282
+ const input = inputRef.current;
283
+ if (input) lastSelectionRef.current = { start: input.selectionStart, end: input.selectionEnd };
284
+ }}
285
+ onBlur={() => multi.clear()}
323
286
  placeholder={placeholder}
324
287
  readOnly={readOnly}
325
288
  autoFocus={autoFocus}
@@ -329,7 +292,9 @@ export function CodeEditor({
329
292
  spellcheck={false}
330
293
  data-gramm={false}
331
294
  />
332
- <EditorResetStyle />
295
+ {/* One <style> per editor instance; the rule is a constant so the
296
+ duplicate elements are the same three lines the browser dedupes. */}
297
+ <style dangerouslySetInnerHTML={{ __html: CODE_EDITOR_RESET_CSS }} />
333
298
  </div>
334
299
  );
335
300
  }