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.
Files changed (125) hide show
  1. package/README.md +24 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  4. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  5. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  6. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  7. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  8. package/dist/client/static/js/async/c.797d933882.js +1 -0
  9. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  10. package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
  11. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  12. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  13. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  17. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  18. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  19. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  20. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  21. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  22. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  23. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  24. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  25. package/package.json +3 -1
  26. package/src/cli/lib/args.js +1 -1
  27. package/src/cli/lib/commands/restart.js +11 -0
  28. package/src/cli/lib/commands/update.js +21 -7
  29. package/src/cli/lib/runtime.js +6 -3
  30. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  31. package/src/client/components/common/code-editor/history.test.ts +118 -0
  32. package/src/client/components/common/code-editor/history.ts +101 -0
  33. package/src/client/components/common/code-editor/index.tsx +56 -66
  34. package/src/client/components/common/code-surface/index.tsx +84 -11
  35. package/src/client/components/common/code-surface/measure.ts +66 -17
  36. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  37. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  38. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  40. package/src/client/components/settings/LazyModal.tsx +1 -1
  41. package/src/client/components/settings/Modal.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  43. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  44. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  45. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  56. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  57. package/src/client/hooks/chat/omp/index.ts +4 -109
  58. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  59. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  60. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  61. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  62. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  63. package/src/client/hooks/chat/timeline/index.ts +6 -8
  64. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  65. package/src/client/hooks/chat/timeline/send.ts +16 -29
  66. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  67. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  68. package/src/client/hooks/editor/use-code-window.ts +118 -0
  69. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  70. package/src/client/hooks/ui/notification-sound.ts +65 -0
  71. package/src/client/hooks/ui/theme.ts +22 -6
  72. package/src/client/tailwind.css +38 -12
  73. package/src/server/index.ts +4 -3
  74. package/src/server/lib/lifecycle/bun.ts +18 -0
  75. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  76. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  77. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  78. package/src/server/lib/lifecycle/restart.ts +111 -0
  79. package/src/server/lib/omp/rpc/manager.ts +14 -3
  80. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  81. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  82. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  83. package/src/server/lib/updates/apply.ts +8 -5
  84. package/src/server/lib/updates/install.ts +7 -4
  85. package/src/server/routes/health/probe.ts +2 -1
  86. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  87. package/src/shared/lib/chat/ask-questions.ts +264 -0
  88. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  89. package/src/shared/lib/code/lazy-window.ts +156 -0
  90. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  91. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  92. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  93. package/src/shared/lib/markdown/mermaid.ts +8 -6
  94. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  95. package/src/shared/types/omp/agent.ts +2 -2
  96. package/src/shared/types/workspace.ts +4 -0
  97. package/dist/client/static/css/index.51f68210a5.css +0 -2
  98. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  99. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  100. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  101. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  102. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  103. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  104. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  105. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  106. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  107. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  108. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  109. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  110. package/dist/client/static/js/index.47d3b04d5f.js +0 -33
  111. package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
  112. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  113. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  114. /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  115. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  116. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  117. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  118. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  119. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  120. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  121. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  122. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  123. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  124. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  125. /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 given code. */
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<History>({ stack: [], offset: -1 });
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
- const record: HistoryRecord = {
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.stack.push({ ...record, timestamp: Date.now() });
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 { stack, offset } = historyRef.current;
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 { stack, offset } = historyRef.current;
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 { stack, offset } = historyRef.current;
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 (last && timestamp - last.timestamp < HISTORY_TIME_GAP) {
272
- const re = /[^a-z0-9]([a-z0-9]+)$/i;
273
- const previous = getLines(last.value, last.selectionStart).pop()?.match(re);
274
- const current = getLines(next, selectionStart).pop()?.match(re);
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
- historyRef.current.stack.push({ value: next, selectionStart, selectionEnd, timestamp });
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, ...contentStyle, position: 'relative', pointerEvents: 'none' }}
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 { highlightCode } from '@/shared/lib/code/syntax-highlight';
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
- const next = wordWrap && pre ? measureWrappedLines(pre, value) : null;
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
- }, [value, wordWrap, editorPadding, editorStyle?.fontSize]);
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
- {value.split('\n').map((_, i) => (
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={i + 1}
172
+ key={index + 1}
101
173
  className={gutterLineClassName}
102
- style={wrapped ? { height: wrapped.heights[i], flexShrink: 0 } : undefined}
174
+ style={lineHeights ? { height: lineHeights[index], flexShrink: 0 } : undefined}
103
175
  >
104
- {i + 1}
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={(code) => highlightCode(code, language)}
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
- if (typeof document === 'undefined' || !source.isConnected) return null;
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
- while (mirrorLines.length > lines.length) mirrorLines.pop()?.remove();
84
- while (mirrorLines.length < lines.length) {
85
- const line = document.createElement('div');
86
- host.appendChild(line);
87
- mirrorLines.push(line);
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
- // Write the whole text first, then read the whole layout: interleaving the
90
- // two would force a re-layout per line.
91
- for (let i = 0; i < lines.length; i++) mirrorLines[i].textContent = lines[i];
92
- const heights = mirrorLines.map((line) => {
93
- const height = line.getBoundingClientRect().height;
94
- // An empty line has no line box at all, and sub-pixel wrapping would
95
- // otherwise leave the gutter fractions of a row out of step.
96
- return height > rowHeight ? Math.round(height / rowHeight) * rowHeight : rowHeight;
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
  }