ompchamber 0.6.0 → 0.8.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.
- package/README.md +24 -8
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.5c0447cefe.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +3 -1
- package/src/cli/lib/args.js +1 -1
- package/src/cli/lib/commands/restart.js +11 -0
- package/src/cli/lib/commands/update.js +21 -7
- package/src/cli/lib/runtime.js +6 -3
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/hooks/chat/omp/index.ts +4 -109
- package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
- package/src/client/hooks/chat/omp/session-list.tsx +4 -0
- package/src/client/hooks/chat/timeline/actions.ts +0 -9
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +6 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/send.ts +16 -29
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/tailwind.css +38 -12
- package/src/server/index.ts +4 -3
- package/src/server/lib/lifecycle/bun.ts +18 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
- package/src/server/lib/lifecycle/launch-mode.ts +40 -0
- package/src/server/lib/lifecycle/restart.test.ts +21 -0
- package/src/server/lib/lifecycle/restart.ts +111 -0
- package/src/server/lib/omp/rpc/manager.ts +14 -3
- package/src/server/lib/omp/rpc/session-commands.ts +15 -1
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
- package/src/server/lib/updates/apply.ts +8 -5
- package/src/server/lib/updates/install.ts +7 -4
- package/src/server/routes/health/probe.ts +2 -1
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
- package/src/shared/types/omp/agent.ts +2 -2
- package/src/shared/types/workspace.ts +4 -0
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.47d3b04d5f.js +0 -33
- package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { useEffect, 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';
|
|
13
|
+
import type { CodeWindow, LineWindow } from '@/shared/lib/code/lazy-window';
|
|
14
|
+
|
|
4
15
|
/**
|
|
5
16
|
* Preact hand-roll of the textarea-over-<pre> highlighting editor
|
|
6
17
|
* (the pattern react-simple-code-editor popularized), trimmed to the
|
|
@@ -9,28 +20,18 @@ import type { CSSProperties, Ref, TargetedKeyboardEvent } from 'preact';
|
|
|
9
20
|
* How it works: a transparent <textarea> sits on top of a syntax-highlighted
|
|
10
21
|
* <pre>. Typing, selecting and copying hit the native textarea; the <pre>
|
|
11
22
|
* below just mirrors the text with Shiki markup.
|
|
23
|
+
*
|
|
24
|
+
* For a long document only a window of lines is mirrored (`virtual`): the
|
|
25
|
+
* textarea still holds every line, so editing, selection and copy are
|
|
26
|
+
* untouched, while the `<pre>` carries just the visible ones and reserves the
|
|
27
|
+
* rest as padding — the alignment that keeps the caret over its own row.
|
|
12
28
|
*/
|
|
13
29
|
|
|
14
|
-
const HISTORY_LIMIT = 100;
|
|
15
|
-
const HISTORY_TIME_GAP = 3000;
|
|
16
|
-
|
|
17
|
-
interface HistoryRecord {
|
|
18
|
-
value: string;
|
|
19
|
-
selectionStart: number;
|
|
20
|
-
selectionEnd: number;
|
|
21
|
-
timestamp: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface History {
|
|
25
|
-
stack: HistoryRecord[];
|
|
26
|
-
offset: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
30
|
export interface CodeEditorProps {
|
|
30
31
|
value: string;
|
|
31
32
|
onValueChange: (value: string) => void;
|
|
32
|
-
/** Return the Shiki-highlighted HTML for the
|
|
33
|
-
highlight: (code: string) => string;
|
|
33
|
+
/** Return the Shiki-highlighted HTML for `code`, limited to `range` when the surface is windowed. */
|
|
34
|
+
highlight: (code: string, range: LineWindow | null) => string;
|
|
34
35
|
padding?: number;
|
|
35
36
|
/** Class applied to the inner <pre> holding the highlighted code. */
|
|
36
37
|
preClassName?: string;
|
|
@@ -44,6 +45,8 @@ export interface CodeEditorProps {
|
|
|
44
45
|
placeholder?: string;
|
|
45
46
|
readOnly?: boolean;
|
|
46
47
|
autoFocus?: boolean;
|
|
48
|
+
/** Renders only this window of lines, with the rest reserved as space. Omit to mirror the whole value. */
|
|
49
|
+
virtual?: CodeWindow | null;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
const BASE_TEXTAREA_CLASS = 'code-editor-native-textarea';
|
|
@@ -80,9 +83,10 @@ export function CodeEditor({
|
|
|
80
83
|
placeholder,
|
|
81
84
|
readOnly,
|
|
82
85
|
autoFocus,
|
|
86
|
+
virtual,
|
|
83
87
|
}: CodeEditorProps) {
|
|
84
88
|
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
85
|
-
const historyRef = useRef
|
|
89
|
+
const historyRef = useRef(createHistory());
|
|
86
90
|
const [captureTab, setCaptureTab] = useState(true);
|
|
87
91
|
|
|
88
92
|
// Re-record when the controlled value is replaced from the outside
|
|
@@ -90,18 +94,12 @@ export function CodeEditor({
|
|
|
90
94
|
useEffect(() => {
|
|
91
95
|
const input = inputRef.current;
|
|
92
96
|
if (!input) return;
|
|
93
|
-
|
|
97
|
+
pushRecord(historyRef.current, {
|
|
94
98
|
value: input.value,
|
|
95
99
|
selectionStart: input.selectionStart,
|
|
96
100
|
selectionEnd: input.selectionEnd,
|
|
97
101
|
timestamp: Date.now(),
|
|
98
|
-
};
|
|
99
|
-
const { stack, offset } = historyRef.current;
|
|
100
|
-
if (stack.length && offset > -1) {
|
|
101
|
-
historyRef.current.stack = stack.slice(0, offset + 1);
|
|
102
|
-
}
|
|
103
|
-
historyRef.current.stack.push(record);
|
|
104
|
-
historyRef.current.offset = historyRef.current.stack.length - 1;
|
|
102
|
+
});
|
|
105
103
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
104
|
}, [value]);
|
|
107
105
|
|
|
@@ -109,19 +107,15 @@ export function CodeEditor({
|
|
|
109
107
|
const input = inputRef.current;
|
|
110
108
|
const last = historyRef.current.stack[historyRef.current.offset];
|
|
111
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
112
|
historyRef.current.stack[historyRef.current.offset] = {
|
|
113
113
|
...last,
|
|
114
114
|
selectionStart: input.selectionStart,
|
|
115
115
|
selectionEnd: input.selectionEnd,
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
|
-
historyRef.current
|
|
119
|
-
historyRef.current.offset = historyRef.current.stack.length - 1;
|
|
120
|
-
if (historyRef.current.stack.length > HISTORY_LIMIT) {
|
|
121
|
-
const extras = historyRef.current.stack.length - HISTORY_LIMIT;
|
|
122
|
-
historyRef.current.stack = historyRef.current.stack.slice(extras);
|
|
123
|
-
historyRef.current.offset = Math.max(historyRef.current.offset - extras, 0);
|
|
124
|
-
}
|
|
118
|
+
pushEdit(historyRef.current, { ...record, timestamp: Date.now() });
|
|
125
119
|
onValueChange(record.value);
|
|
126
120
|
if (input) {
|
|
127
121
|
input.value = record.value;
|
|
@@ -220,14 +214,12 @@ export function CodeEditor({
|
|
|
220
214
|
const undoKey = isMac ? e.metaKey && e.key.toLowerCase() === 'z' : e.ctrlKey && e.key.toLowerCase() === 'z';
|
|
221
215
|
if (undoKey && !e.shiftKey && !e.altKey) {
|
|
222
216
|
e.preventDefault();
|
|
223
|
-
const
|
|
224
|
-
const record = stack[offset - 1];
|
|
217
|
+
const record = stepHistory(historyRef.current, 'undo');
|
|
225
218
|
if (record && inputRef.current) {
|
|
226
219
|
inputRef.current.value = record.value;
|
|
227
220
|
inputRef.current.selectionStart = record.selectionStart;
|
|
228
221
|
inputRef.current.selectionEnd = record.selectionEnd;
|
|
229
222
|
onValueChange(record.value);
|
|
230
|
-
historyRef.current.offset = Math.max(offset - 1, 0);
|
|
231
223
|
}
|
|
232
224
|
return;
|
|
233
225
|
}
|
|
@@ -239,14 +231,12 @@ export function CodeEditor({
|
|
|
239
231
|
: e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'z';
|
|
240
232
|
if (redoKey && !e.altKey) {
|
|
241
233
|
e.preventDefault();
|
|
242
|
-
const
|
|
243
|
-
const record = stack[offset + 1];
|
|
234
|
+
const record = stepHistory(historyRef.current, 'redo');
|
|
244
235
|
if (record && inputRef.current) {
|
|
245
236
|
inputRef.current.value = record.value;
|
|
246
237
|
inputRef.current.selectionStart = record.selectionStart;
|
|
247
238
|
inputRef.current.selectionEnd = record.selectionEnd;
|
|
248
239
|
onValueChange(record.value);
|
|
249
|
-
historyRef.current.offset = Math.min(offset + 1, stack.length - 1);
|
|
250
240
|
}
|
|
251
241
|
return;
|
|
252
242
|
}
|
|
@@ -261,37 +251,18 @@ export function CodeEditor({
|
|
|
261
251
|
const handleChange = (e: Event) => {
|
|
262
252
|
const input = e.target as HTMLTextAreaElement;
|
|
263
253
|
const { value: next, selectionStart, selectionEnd } = input;
|
|
264
|
-
const
|
|
254
|
+
const history = historyRef.current;
|
|
265
255
|
const timestamp = Date.now();
|
|
266
|
-
if (stack.length && offset > -1) {
|
|
267
|
-
historyRef.current.stack = stack.slice(0, offset + 1);
|
|
268
|
-
}
|
|
269
|
-
const last = historyRef.current.stack[historyRef.current.offset];
|
|
270
256
|
// Merge typing bursts into one word-level undo entry.
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (previous?.[1] && current?.[1]?.startsWith(previous[1])) {
|
|
276
|
-
historyRef.current.stack[historyRef.current.offset] = { value: next, selectionStart, selectionEnd, timestamp };
|
|
277
|
-
onValueChange(next);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
257
|
+
if (continuesTyping(history.stack[history.offset], next, selectionStart, timestamp)) {
|
|
258
|
+
mergeTyping(history, next, selectionStart, selectionEnd, timestamp);
|
|
259
|
+
onValueChange(next);
|
|
260
|
+
return;
|
|
280
261
|
}
|
|
281
|
-
|
|
282
|
-
historyRef.current.offset = historyRef.current.stack.length - 1;
|
|
262
|
+
pushRecord(history, { value: next, selectionStart, selectionEnd, timestamp });
|
|
283
263
|
onValueChange(next);
|
|
284
264
|
};
|
|
285
265
|
|
|
286
|
-
const contentStyle: CSSProperties = {
|
|
287
|
-
paddingTop: padding,
|
|
288
|
-
paddingRight: padding,
|
|
289
|
-
paddingBottom: padding,
|
|
290
|
-
paddingLeft: padding,
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
const highlighted = highlight(value);
|
|
294
|
-
|
|
295
266
|
const layerStyle: CSSProperties = {
|
|
296
267
|
margin: 0,
|
|
297
268
|
border: 0,
|
|
@@ -314,14 +285,33 @@ export function CodeEditor({
|
|
|
314
285
|
overflowWrap: 'break-word',
|
|
315
286
|
};
|
|
316
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
|
+
};
|
|
304
|
+
|
|
305
|
+
const highlighted = highlight(value, virtual ?? null);
|
|
306
|
+
|
|
317
307
|
return (
|
|
318
308
|
<div className={className} style={{ position: 'relative', textAlign: 'left', boxSizing: 'border-box', padding: 0, overflow: 'hidden', ...style }}>
|
|
319
309
|
<pre
|
|
320
310
|
ref={preRef}
|
|
321
311
|
className={preClassName}
|
|
322
312
|
aria-hidden="true"
|
|
323
|
-
style={{ ...layerStyle, ...
|
|
324
|
-
dangerouslySetInnerHTML={{ __html: highlighted + '<br />' }}
|
|
313
|
+
style={{ ...layerStyle, ...windowStyle, position: 'relative', pointerEvents: 'none' }}
|
|
314
|
+
dangerouslySetInnerHTML={{ __html: highlighted + (virtual && !virtual.breakAtEnd ? '' : '<br />') }}
|
|
325
315
|
/>
|
|
326
316
|
<textarea
|
|
327
317
|
ref={inputRef}
|
|
@@ -1,13 +1,30 @@
|
|
|
1
|
-
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import type { CSSProperties } from 'preact';
|
|
3
|
+
|
|
3
4
|
import { CodeEditor } from '@/client/components/common/code-editor';
|
|
4
|
-
import { measureWrappedLines, type WrappedLines } from '@/client/components/common/code-surface/measure';
|
|
5
|
-
import {
|
|
5
|
+
import { measureRowHeight, measureWrappedLines, type WrappedLines } from '@/client/components/common/code-surface/measure';
|
|
6
|
+
import { useCodeWindow } from '@/client/hooks/editor/use-code-window';
|
|
6
7
|
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
8
|
+
import {
|
|
9
|
+
measuredGeometry,
|
|
10
|
+
shouldWindow,
|
|
11
|
+
uniformGeometry,
|
|
12
|
+
type LineGeometry,
|
|
13
|
+
type LineWindow,
|
|
14
|
+
} from '@/shared/lib/code/lazy-window';
|
|
15
|
+
import { highlightCode } from '@/shared/lib/code/syntax-highlight';
|
|
16
|
+
import { highlightCodeWindow } from '@/shared/lib/code/windowed-highlight';
|
|
7
17
|
|
|
8
18
|
const WRAP_ON = '!whitespace-pre-wrap !break-words';
|
|
9
19
|
const WRAP_OFF = '!whitespace-pre !break-normal';
|
|
10
20
|
|
|
21
|
+
/** Line count without materializing every line — a 20 000-line value per render is a megabyte of strings. */
|
|
22
|
+
function countLines(text: string): number {
|
|
23
|
+
let count = 1;
|
|
24
|
+
for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) count++;
|
|
25
|
+
return count;
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
interface CodeSurfaceProps {
|
|
12
29
|
value: string;
|
|
13
30
|
onValueChange: (value: string) => void;
|
|
@@ -47,6 +64,12 @@ function sameWrappedLines(previous: WrappedLines | null, next: WrappedLines | nu
|
|
|
47
64
|
* The gutter spaces its numbers from the editor's measured wrap layout: with
|
|
48
65
|
* word wrap on, a long line covers several rows and every number below it has
|
|
49
66
|
* to move down by the same amount.
|
|
67
|
+
*
|
|
68
|
+
* A long document is drawn lazily: only the lines inside the scroll container's
|
|
69
|
+
* viewport are highlighted and numbered, and the space of everything else is
|
|
70
|
+
* reserved from the same geometry the caret is aligned to. That keeps opening a
|
|
71
|
+
* 20 000-line file from spending minutes in the tokenizer, and a keystroke in it
|
|
72
|
+
* from re-tokenizing the file at all.
|
|
50
73
|
*/
|
|
51
74
|
export function CodeSurface({
|
|
52
75
|
value,
|
|
@@ -64,16 +87,29 @@ export function CodeSurface({
|
|
|
64
87
|
}: CodeSurfaceProps) {
|
|
65
88
|
const wrapClass = wordWrap ? WRAP_ON : WRAP_OFF;
|
|
66
89
|
const preRef = useRef<HTMLPreElement | null>(null);
|
|
90
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
67
91
|
const [wrapped, setWrapped] = useState<WrappedLines | null>(null);
|
|
92
|
+
const [rowHeight, setRowHeight] = useState<number | null>(null);
|
|
93
|
+
/** Lines the last measurement pass described; -1 before the first one runs. */
|
|
94
|
+
const [measuredLines, setMeasuredLines] = useState(-1);
|
|
68
95
|
useSyntaxReady();
|
|
69
96
|
|
|
97
|
+
const lineCount = useMemo(() => countLines(value), [value]);
|
|
98
|
+
|
|
70
99
|
// Re-measured on the inputs that move a wrap point: the text, the wrap
|
|
71
100
|
// preference, and the editor's font size / padding.
|
|
72
101
|
const measure = useCallback(() => {
|
|
73
102
|
const pre = preRef.current;
|
|
74
|
-
|
|
103
|
+
if (!pre) return;
|
|
104
|
+
// Wrapped heights are only needed with word wrap on; without it every line
|
|
105
|
+
// is one row and the row height alone describes the document.
|
|
106
|
+
const next = wordWrap ? measureWrappedLines(pre, value) : null;
|
|
75
107
|
setWrapped((previous) => (sameWrappedLines(previous, next) ? previous : next));
|
|
76
|
-
|
|
108
|
+
const height = next?.rowHeight ?? measureRowHeight(pre);
|
|
109
|
+
setRowHeight((previous) => (previous === height ? previous : height));
|
|
110
|
+
setMeasuredLines(lineCount);
|
|
111
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
+
}, [value, lineCount, wordWrap, editorPadding, editorStyle?.fontSize]);
|
|
77
113
|
|
|
78
114
|
useLayoutEffect(() => {
|
|
79
115
|
measure();
|
|
@@ -92,16 +128,52 @@ export function CodeSurface({
|
|
|
92
128
|
return () => observer.disconnect();
|
|
93
129
|
}, []);
|
|
94
130
|
|
|
131
|
+
const geometry = useMemo<LineGeometry | null>(() => {
|
|
132
|
+
if (rowHeight === null) return null;
|
|
133
|
+
return wrapped
|
|
134
|
+
? measuredGeometry(wrapped.heights, wrapped.rowHeight, lineCount)
|
|
135
|
+
: uniformGeometry(rowHeight, lineCount);
|
|
136
|
+
}, [rowHeight, wrapped, lineCount]);
|
|
137
|
+
|
|
138
|
+
// Windowing costs a scroll listener and a spacer; a document short enough to
|
|
139
|
+
// draw in one pass keeps the plain surface.
|
|
140
|
+
const lazy = shouldWindow(lineCount, value.length);
|
|
141
|
+
const virtual = useCodeWindow({
|
|
142
|
+
rootRef,
|
|
143
|
+
lineCount,
|
|
144
|
+
geometry,
|
|
145
|
+
// A document whose geometry could not be measured still falls back to the
|
|
146
|
+
// plain surface — but one whose measurement has not run *yet* must not:
|
|
147
|
+
// that path would draw the whole file, which is what windowing exists to
|
|
148
|
+
// avoid. `measuredLines` is why switching files (which leaves a geometry
|
|
149
|
+
// for the previous document behind) does not take it either.
|
|
150
|
+
enabled: lazy && (measuredLines !== lineCount || geometry !== null),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const highlight = useCallback(
|
|
154
|
+
(code: string, range: LineWindow | null) =>
|
|
155
|
+
range ? highlightCodeWindow(code, language, range) : highlightCode(code, language),
|
|
156
|
+
[language],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const firstLine = virtual ? virtual.start : 0;
|
|
160
|
+
const lastLine = virtual ? Math.min(virtual.end, lineCount) : lineCount;
|
|
161
|
+
const rows: number[] = [];
|
|
162
|
+
for (let i = firstLine; i < lastLine; i++) rows.push(i);
|
|
163
|
+
// Row heights are only usable for the document they were measured against.
|
|
164
|
+
const lineHeights = wrapped && wrapped.heights.length === lineCount ? wrapped.heights : null;
|
|
165
|
+
|
|
95
166
|
return (
|
|
96
|
-
<div className={rootClassName}>
|
|
167
|
+
<div className={rootClassName} ref={rootRef}>
|
|
97
168
|
<div className={gutterClassName} style={gutterStyle}>
|
|
98
|
-
{
|
|
169
|
+
{virtual && virtual.top > 0 ? <div style={{ height: virtual.top, flexShrink: 0 }} aria-hidden="true" /> : null}
|
|
170
|
+
{rows.map((index) => (
|
|
99
171
|
<div
|
|
100
|
-
key={
|
|
172
|
+
key={index + 1}
|
|
101
173
|
className={gutterLineClassName}
|
|
102
|
-
style={
|
|
174
|
+
style={lineHeights ? { height: lineHeights[index], flexShrink: 0 } : undefined}
|
|
103
175
|
>
|
|
104
|
-
{
|
|
176
|
+
{index + 1}
|
|
105
177
|
</div>
|
|
106
178
|
))}
|
|
107
179
|
</div>
|
|
@@ -109,13 +181,14 @@ export function CodeSurface({
|
|
|
109
181
|
<CodeEditor
|
|
110
182
|
value={value}
|
|
111
183
|
onValueChange={onValueChange}
|
|
112
|
-
highlight={
|
|
184
|
+
highlight={highlight}
|
|
113
185
|
padding={editorPadding}
|
|
114
186
|
preRef={preRef}
|
|
115
187
|
textareaClassName={`focus:outline-none ${wrapClass}`}
|
|
116
188
|
preClassName={wrapClass}
|
|
117
189
|
style={editorStyle}
|
|
118
190
|
className={editorClassName}
|
|
191
|
+
virtual={virtual}
|
|
119
192
|
/>
|
|
120
193
|
</div>
|
|
121
194
|
</div>
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* width the editor actually got — so it is measured rather than estimated: the
|
|
9
9
|
* same lines are laid out once in a hidden mirror that copies the editor's
|
|
10
10
|
* typography, and each line's height is read back.
|
|
11
|
+
*
|
|
12
|
+
* Measuring every line on every keystroke is linear in the document, which no
|
|
13
|
+
* long file can afford, so heights are cached per line *text*: an edit re-reads
|
|
14
|
+
* the one line it touched, and a document whose lines all fit on one row is
|
|
15
|
+
* never mirrored at all.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
/**
|
|
@@ -34,6 +39,13 @@ const MIRRORED_PROPERTIES = [
|
|
|
34
39
|
'wordSpacing',
|
|
35
40
|
] as const;
|
|
36
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Newest-first ceiling on the height cache. A long editing session mints one
|
|
44
|
+
* entry per keystroke, so the oldest entries are dropped once it is reached;
|
|
45
|
+
* whatever leaves the cache is simply measured again on the next pass.
|
|
46
|
+
*/
|
|
47
|
+
const HEIGHT_CACHE_LIMIT = 100_000;
|
|
48
|
+
|
|
37
49
|
export interface WrappedLines {
|
|
38
50
|
/** Height in px of a single visual row. */
|
|
39
51
|
rowHeight: number;
|
|
@@ -43,6 +55,11 @@ export interface WrappedLines {
|
|
|
43
55
|
|
|
44
56
|
let mirrorHost: HTMLDivElement | null = null;
|
|
45
57
|
let mirrorLines: HTMLDivElement[] = [];
|
|
58
|
+
/** The column the cached heights were measured in; a new one re-wraps every line. */
|
|
59
|
+
let mirrorSignature = '';
|
|
60
|
+
const heightByLine = new Map<string, number>();
|
|
61
|
+
/** Line indexes awaiting a mirror read this pass; reused to keep the pass allocation-free. */
|
|
62
|
+
const pendingLines: number[] = [];
|
|
46
63
|
|
|
47
64
|
/** Off-screen mirror, reused across measurements (one per page). */
|
|
48
65
|
function getMirrorHost(): HTMLDivElement {
|
|
@@ -57,16 +74,22 @@ function getMirrorHost(): HTMLDivElement {
|
|
|
57
74
|
return mirrorHost;
|
|
58
75
|
}
|
|
59
76
|
|
|
77
|
+
/** Height in px of `source`'s line box, or null while it cannot be measured (SSR, `line-height: normal`). */
|
|
78
|
+
export function measureRowHeight(source: HTMLPreElement): number | null {
|
|
79
|
+
if (typeof document === 'undefined' || !source.isConnected) return null;
|
|
80
|
+
const rowHeight = Number.parseFloat(getComputedStyle(source).lineHeight);
|
|
81
|
+
return Number.isFinite(rowHeight) && rowHeight > 0 ? rowHeight : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
/**
|
|
61
85
|
* Height each line of `value` occupies in `source`'s wrap layout, or null when
|
|
62
86
|
* the layout is not measurable yet (SSR, zero width, unresolved line-height) —
|
|
63
87
|
* callers then fall back to one row per line.
|
|
64
88
|
*/
|
|
65
89
|
export function measureWrappedLines(source: HTMLPreElement, value: string): WrappedLines | null {
|
|
66
|
-
|
|
90
|
+
const rowHeight = measureRowHeight(source);
|
|
91
|
+
if (rowHeight === null) return null;
|
|
67
92
|
const computed = getComputedStyle(source);
|
|
68
|
-
const rowHeight = Number.parseFloat(computed.lineHeight);
|
|
69
|
-
if (!Number.isFinite(rowHeight) || rowHeight <= 0) return null;
|
|
70
93
|
// The editor's text column: its padding is the gutter's offset, not text area.
|
|
71
94
|
const width =
|
|
72
95
|
source.clientWidth -
|
|
@@ -79,21 +102,47 @@ export function measureWrappedLines(source: HTMLPreElement, value: string): Wrap
|
|
|
79
102
|
host.style.lineHeight = `${rowHeight}px`;
|
|
80
103
|
host.style.width = `${width}px`;
|
|
81
104
|
|
|
105
|
+
const signature = `${width}|${rowHeight}|${MIRRORED_PROPERTIES.map((property) => computed[property]).join('|')}`;
|
|
106
|
+
if (signature !== mirrorSignature) {
|
|
107
|
+
mirrorSignature = signature;
|
|
108
|
+
heightByLine.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
82
111
|
const lines = value.split('\n');
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
const heights = new Array<number>(lines.length);
|
|
113
|
+
let pending = 0;
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const cached = heightByLine.get(lines[i]);
|
|
116
|
+
if (cached === undefined) pendingLines[pending++] = i;
|
|
117
|
+
else heights[i] = cached;
|
|
88
118
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
|
|
120
|
+
if (pending > 0) {
|
|
121
|
+
while (mirrorLines.length < pending) {
|
|
122
|
+
const line = document.createElement('div');
|
|
123
|
+
host.appendChild(line);
|
|
124
|
+
mirrorLines.push(line);
|
|
125
|
+
}
|
|
126
|
+
// Write the whole batch first, then read the whole layout: interleaving the
|
|
127
|
+
// two would force a re-layout per line.
|
|
128
|
+
for (let k = 0; k < pending; k++) mirrorLines[k].textContent = lines[pendingLines[k]];
|
|
129
|
+
for (let k = 0; k < pending; k++) {
|
|
130
|
+
const index = pendingLines[k];
|
|
131
|
+
const height = mirrorLines[k].getBoundingClientRect().height;
|
|
132
|
+
// An empty line has no line box at all, and sub-pixel wrapping would
|
|
133
|
+
// otherwise leave the gutter fractions of a row out of step.
|
|
134
|
+
const rows = height > rowHeight ? Math.round(height / rowHeight) * rowHeight : rowHeight;
|
|
135
|
+
heights[index] = rows;
|
|
136
|
+
heightByLine.set(lines[index], rows);
|
|
137
|
+
}
|
|
138
|
+
if (heightByLine.size > HEIGHT_CACHE_LIMIT) {
|
|
139
|
+
let drop = heightByLine.size - HEIGHT_CACHE_LIMIT / 2;
|
|
140
|
+
for (const key of heightByLine.keys()) {
|
|
141
|
+
if (drop-- <= 0) break;
|
|
142
|
+
heightByLine.delete(key);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
98
147
|
return { rowHeight, heights };
|
|
99
148
|
}
|