@adecore/editor 0.41.1 → 0.43.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/src/fake.ts CHANGED
@@ -46,9 +46,12 @@ import type {
46
46
  EditorSemanticToken,
47
47
  EditorSmartKeys,
48
48
  EditorApplyEditsOptions,
49
+ EditorCopy,
50
+ EditorPasteOptions,
49
51
  EditorTextChange,
50
52
  EditorTrackedRange,
51
53
  EditorTheme,
54
+ EditorWhitespaceCleanup,
52
55
  EditorWidget
53
56
  } from './types.ts';
54
57
  import { emit, type Listener, subscribe } from './listeners.ts';
@@ -254,6 +257,8 @@ export class FakeEditor implements Editor {
254
257
  this.readOnly = options.readOnly ?? false;
255
258
  this.readOnlyReason = options.readOnlyReason;
256
259
  this.label = options.label ?? 'Code editor';
260
+ this.todoWords = options.todoWords;
261
+ this.fontSize = options.fontSize ?? null;
257
262
  this.revealedLine = options.line ?? null;
258
263
  this.column = options.column ?? null;
259
264
  this.scrollTop = options.scrollTop ?? null;
@@ -810,10 +815,88 @@ export class FakeEditor implements Editor {
810
815
  this.label = label;
811
816
  }
812
817
 
818
+ todoWords: readonly string[] | undefined;
819
+
820
+ setTodoWords(words: readonly string[]): void {
821
+ this.todoWords = [...words];
822
+ }
823
+
813
824
  setFoldHints(hints: EditorFoldHints | null): void {
814
825
  this.foldHints = hints;
815
826
  }
816
827
 
828
+ declarations: readonly EditorPosition[] | null = null;
829
+
830
+ setDeclarations(positions: readonly EditorPosition[] | null): void {
831
+ this.declarations = positions;
832
+ }
833
+
834
+ private readonly navigations = new Set<(from: EditorPosition) => void>();
835
+
836
+ onNavigate(listener: (from: EditorPosition) => void): () => void {
837
+ return subscribe(this.navigations, listener);
838
+ }
839
+
840
+ /* Stands for a command that navigated: tells the listeners the caret left `from`. */
841
+ navigate(from: EditorPosition): void {
842
+ emit(this.navigations, from);
843
+ }
844
+
845
+ hasFocus(): boolean {
846
+ return this.focused;
847
+ }
848
+
849
+ private readonly copies = new Set<(copy: EditorCopy) => void>();
850
+
851
+ onCopy(listener: (copy: EditorCopy) => void): () => void {
852
+ return subscribe(this.copies, listener);
853
+ }
854
+
855
+ /* Stands for a copy or cut of a person: tells the listeners what went on the clipboard. */
856
+ copy(text: string, wholeLines = false, cut = false): void {
857
+ emit(this.copies, { text, wholeLines, cut });
858
+ }
859
+
860
+ /* Every paste a host asked for, with its options. */
861
+ readonly pastes: Array<{ text: string; options: EditorPasteOptions | undefined }> = [];
862
+
863
+ /* Puts the text at every caret, one line per caret when the lines are as many as the carets, and leaves the carets behind it. */
864
+ paste(text: string, options?: EditorPasteOptions): boolean {
865
+ this.assertLive();
866
+ this.pastes.push({ text, options });
867
+
868
+ if (this.readOnly || text === '') {
869
+ return false;
870
+ }
871
+
872
+ const lines = text.split('\n');
873
+ const selections = [...this.selections].sort((left, right) => Math.min(left.anchor, left.head) - Math.min(right.anchor, right.head));
874
+ const perCaret = lines.length === selections.length;
875
+
876
+ const edits = selections.map((selection, index) => ({
877
+ from: Math.min(selection.anchor, selection.head),
878
+ to: Math.max(selection.anchor, selection.head),
879
+ text: perCaret ? lines[index]! : text
880
+ }));
881
+
882
+ return this.edit(edits, 'command', false, () => {
883
+ let shift = 0;
884
+
885
+ return edits.map((edit) => {
886
+ const caret = edit.from + shift + edit.text.length;
887
+ shift += edit.text.length - (edit.to - edit.from);
888
+
889
+ return { anchor: caret, head: caret };
890
+ });
891
+ });
892
+ }
893
+
894
+ wordSources: (() => readonly string[]) | null = null;
895
+
896
+ setWordSources(source: (() => readonly string[]) | null): void {
897
+ this.wordSources = source;
898
+ }
899
+
817
900
  setSmartKeys(keys: Partial<EditorSmartKeys>): void {
818
901
  this.smartKeys = keys;
819
902
  }
@@ -838,11 +921,17 @@ export class FakeEditor implements Editor {
838
921
  this.markers = markers;
839
922
  }
840
923
 
841
- /* What the host drew as hints last. */
924
+ /* What the host drew as hints last, every owner's together. */
842
925
  inlayHints: readonly EditorInlayHint[] = [];
926
+ private readonly inlayOwners = new Map<string, readonly EditorInlayHint[]>();
843
927
 
844
- setInlayHints(hints: readonly EditorInlayHint[]): void {
845
- this.inlayHints = hints;
928
+ setInlayHints(hints: readonly EditorInlayHint[], owner = 'default'): void {
929
+ if (hints.length === 0) {
930
+ this.inlayOwners.delete(owner);
931
+ } else {
932
+ this.inlayOwners.set(owner, hints);
933
+ }
934
+ this.inlayHints = [...this.inlayOwners.values()].flat();
846
935
  }
847
936
 
848
937
  private readonly inlayHovers = new Set<(target: EditorInlayTarget | null) => void>();
@@ -1126,6 +1215,22 @@ export class FakeEditor implements Editor {
1126
1215
  return this.edit(ordered, 'command', options.mergeUndo === true, place);
1127
1216
  }
1128
1217
 
1218
+ /* The options of every cleanup asked for, in order; the fake changes no text for them. */
1219
+ readonly cleanups: EditorWhitespaceCleanup[] = [];
1220
+ /* How often the text was marked as saved, by a cleanup or on its own. */
1221
+ savedMarks = 0;
1222
+
1223
+ cleanUpWhitespace(options: EditorWhitespaceCleanup): boolean {
1224
+ this.cleanups.push({ ...options });
1225
+ this.markSaved();
1226
+
1227
+ return false;
1228
+ }
1229
+
1230
+ markSaved(): void {
1231
+ this.savedMarks += 1;
1232
+ }
1233
+
1129
1234
  setTheme(theme: EditorTheme): void {
1130
1235
  this.theme = theme;
1131
1236
  }
@@ -1134,6 +1239,28 @@ export class FakeEditor implements Editor {
1134
1239
  this.fontRefreshes += 1;
1135
1240
  }
1136
1241
 
1242
+ /* The size `setFontSize` set, null for the page's. */
1243
+ fontSize: number | null = null;
1244
+ private readonly fontSizeListeners = new Set<(size: number | null) => void>();
1245
+
1246
+ setFontSize(size: number | null): void {
1247
+ this.fontSize = size;
1248
+ }
1249
+
1250
+ getFontSize(): number {
1251
+ return this.fontSize ?? 13;
1252
+ }
1253
+
1254
+ onFontSizeChange(listener: (size: number | null) => void): () => void {
1255
+ return subscribe(this.fontSizeListeners, listener);
1256
+ }
1257
+
1258
+ /* What a person zooming would do: the new size goes to the listeners. */
1259
+ zoomTo(size: number | null): void {
1260
+ this.fontSize = size;
1261
+ emit(this.fontSizeListeners, size);
1262
+ }
1263
+
1137
1264
  setReadOnly(readOnly: boolean, reason?: string): void {
1138
1265
  this.readOnly = readOnly;
1139
1266
  this.readOnlyReason = reason;
@@ -1161,6 +1288,8 @@ export class FakeEditor implements Editor {
1161
1288
  this.changes.clear();
1162
1289
  this.textChanges.clear();
1163
1290
  this.carets.clear();
1291
+ this.navigations.clear();
1292
+ this.copies.clear();
1164
1293
  this.hovers.clear();
1165
1294
  this.inlayHovers.clear();
1166
1295
  this.inlayHandlers.clear();
package/src/index.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export { MAX_REPLACEMENT_PAGE, MAX_REPLACEMENT_CHARACTERS } from './search-protocol.ts';
2
2
  export { AGENT_COLORS } from './attribution.ts';
3
3
  export { findDocComments, MAX_DOC_COMMENTS, type DocComment } from '@adecore/editor-core';
4
+ export { DEFAULT_TODO_WORDS, findTodos, type TodoMatch, type TodoOptions } from '@adecore/editor-core';
5
+ export { detectIndentation, type DetectedIndentation } from '@adecore/editor-core';
4
6
  export { createSmartEditorEngine } from './engine.ts';
7
+ export { rememberCopy } from './controller.ts';
5
8
  export { DEFAULT_SMART_KEYS } from './smart-keys.ts';
6
9
  export { shikiScopeColors, shikiTokenizers } from './shiki.ts';
7
10
 
@@ -59,13 +62,17 @@ export type {
59
62
  EditorRemoteCursor,
60
63
  EditorRange,
61
64
  EditorApplyEditsOptions,
65
+ EditorCopy,
66
+ EditorPasteOptions,
62
67
  EditorTextChange,
63
68
  EditorTrackedRange,
64
69
  EditorRunCommand,
65
70
  EditorSelectionRanges,
66
71
  EditorTheme,
67
72
  EditorViewCommand,
73
+ EditorWhitespaceCleanup,
68
74
  EditorWidget,
75
+ EditorZoom,
69
76
  FoldRole,
70
77
  KeyChord,
71
78
  LineToken,
@@ -33,18 +33,42 @@ export const KEYMAP = {
33
33
  deleteLine: bind('Mod+Backspace', 'Mod+Y'),
34
34
  moveLineUp: bind('Alt+Shift+ArrowUp'),
35
35
  moveLineDown: bind('Alt+Shift+ArrowDown'),
36
+ moveStatementUp: bind('Mod+Shift+ArrowUp'),
37
+ moveStatementDown: bind('Mod+Shift+ArrowDown'),
36
38
  toggleLineComment: bind('Mod+/'),
37
39
  toggleBlockComment: bind('Mod+Alt+/', 'Mod+Shift+/'),
38
40
  joinLines: bind('Ctrl+Shift+J'),
39
41
  startNewLine: bind('Shift+Enter'),
42
+ completeCurrentStatement: bind('Mod+Shift+Enter'),
43
+ hippieCompletion: bind('Alt+/'),
44
+ hippieBackwardCompletion: bind('Alt+Shift+/'),
40
45
  startNewLineBefore: bind('Mod+Alt+Enter'),
41
46
  splitLine: bind('Mod+Enter'),
42
47
  toggleCase: bind('Mod+Shift+U'),
43
48
  autoIndentLines: bind('Ctrl+Alt+I'),
49
+ // The platform binds no chord to these, so they live in the menu and the palette.
50
+ sortLines: bind(null),
51
+ reverseLines: bind(null),
52
+ uniqueLines: bind(null),
53
+ convertIndentsToSpaces: bind(null),
54
+ convertIndentsToTabs: bind(null),
44
55
 
45
56
  expandSelection: bind('Alt+ArrowUp', 'Ctrl+W'),
46
- shrinkSelection: bind('Alt+ArrowDown'),
57
+ // Off macOS Alt+ArrowDown is Next Method, as in IntelliJ, so Shrink Selection takes its own chord there.
58
+ shrinkSelection: bind('Alt+ArrowDown', 'Ctrl+Shift+W'),
47
59
  matchBrace: bind('Ctrl+M', 'Ctrl+Shift+M'),
60
+ methodUp: bind('Ctrl+Shift+ArrowUp', 'Alt+ArrowUp'),
61
+ methodDown: bind('Ctrl+Shift+ArrowDown', 'Alt+ArrowDown'),
62
+ lastEditLocation: bind('Mod+Shift+Backspace'),
63
+ nextEditLocation: bind(null),
64
+ pasteFromHistory: bind('Mod+Shift+V'),
65
+ pasteAsPlainText: bind('Mod+Alt+Shift+V'),
66
+ nextChange: bind('Ctrl+Alt+Shift+ArrowDown'),
67
+ previousChange: bind('Ctrl+Alt+Shift+ArrowUp'),
68
+ codeBlockStart: bind('Mod+Alt+[', 'Ctrl+['),
69
+ codeBlockEnd: bind('Mod+Alt+]', 'Ctrl+]'),
70
+ codeBlockStartWithSelection: bind('Mod+Alt+Shift+[', 'Ctrl+Shift+['),
71
+ codeBlockEndWithSelection: bind('Mod+Alt+Shift+]', 'Ctrl+Shift+]'),
48
72
 
49
73
  selectNextOccurrence: bind('Ctrl+G', 'Alt+J'),
50
74
  unselectOccurrence: bind('Ctrl+Shift+G', 'Alt+Shift+J'),
@@ -66,9 +90,10 @@ export const KEYMAP = {
66
90
  smartEnd: bind('Meta+ArrowRight', null),
67
91
  selectSmartEnd: bind('Meta+Shift+ArrowRight', null),
68
92
  textStart: bind('Meta+ArrowUp', null),
69
- selectTextStart: bind('Meta+Shift+ArrowUp', null),
93
+ // IntelliJ binds Cmd+Shift+ArrowUp and ArrowDown to Move Statement on macOS, where Cmd+Shift+Home and End still select to the text edges.
94
+ selectTextStart: bind(null),
70
95
  textEnd: bind('Meta+ArrowDown', null),
71
- selectTextEnd: bind('Meta+Shift+ArrowDown', null),
96
+ selectTextEnd: bind(null),
72
97
 
73
98
  collapse: bind('Mod+-'),
74
99
  expand: bind('Mod+='),
@@ -85,6 +110,10 @@ export const KEYMAP = {
85
110
  expandAllToLevel3: bind(null),
86
111
  expandAllToLevel4: bind(null),
87
112
  expandAllToLevel5: bind(null),
113
+ // The platform binds no chord to these, and the zoom chords of other editors are the fold keys here.
114
+ increaseFontSize: bind(null),
115
+ decreaseFontSize: bind(null),
116
+ resetFontSize: bind(null),
88
117
 
89
118
  triggerCompletion: bind('Ctrl+Space'),
90
119
  parameterInfo: bind('Mod+P'),
@@ -94,6 +123,7 @@ export const KEYMAP = {
94
123
  renameSymbol: bind('Shift+F6'),
95
124
  organizeImports: bind('Ctrl+Alt+O'),
96
125
  formatDocument: bind('Mod+Alt+L'),
126
+ wrapWithAbbreviation: bind('Mod+Alt+J'),
97
127
  selectionToChat: bind(null),
98
128
  inlineEdit: bind(null),
99
129
  suggestInline: bind(null),
@@ -106,6 +136,7 @@ export const KEYMAP = {
106
136
  peekDefinition: bind('Alt+Space', 'Ctrl+Shift+I'),
107
137
  peekReferences: bind('Alt+F7'),
108
138
  callHierarchy: bind('Ctrl+Alt+H'),
139
+ typeHierarchy: bind('Ctrl+H'),
109
140
  toggleRenderedDocs: bind('Ctrl+Alt+Q'),
110
141
  historyBack: bind('Mod+[', 'Ctrl+Alt+ArrowLeft'),
111
142
  historyForward: bind('Mod+]', 'Ctrl+Alt+ArrowRight'),
@@ -123,7 +154,35 @@ export const KEYMAP = {
123
154
  export type KeymapId = keyof typeof KEYMAP;
124
155
 
125
156
  /* Commands added after a host may have written a full keymap of its own; a keymap without one falls back to the built-in chord. */
126
- export type OptionalKeymapId = 'toggleRenderedDocs';
157
+ export type OptionalKeymapId =
158
+ | 'toggleRenderedDocs'
159
+ | 'increaseFontSize'
160
+ | 'decreaseFontSize'
161
+ | 'resetFontSize'
162
+ | 'sortLines'
163
+ | 'reverseLines'
164
+ | 'uniqueLines'
165
+ | 'convertIndentsToSpaces'
166
+ | 'convertIndentsToTabs'
167
+ | 'completeCurrentStatement'
168
+ | 'hippieCompletion'
169
+ | 'hippieBackwardCompletion'
170
+ | 'moveStatementUp'
171
+ | 'moveStatementDown'
172
+ | 'lastEditLocation'
173
+ | 'nextEditLocation'
174
+ | 'methodUp'
175
+ | 'methodDown'
176
+ | 'pasteFromHistory'
177
+ | 'pasteAsPlainText'
178
+ | 'nextChange'
179
+ | 'previousChange'
180
+ | 'codeBlockStart'
181
+ | 'codeBlockEnd'
182
+ | 'codeBlockStartWithSelection'
183
+ | 'codeBlockEndWithSelection'
184
+ | 'wrapWithAbbreviation'
185
+ | 'typeHierarchy';
127
186
 
128
187
  export type Keymap = Readonly<Record<Exclude<KeymapId, OptionalKeymapId>, KeyBinding> & Partial<Record<OptionalKeymapId, KeyBinding>>>;
129
188
  export type KeymapOverrides = Partial<Record<KeymapId, Partial<KeyBinding>>>;
package/src/keymap.ts CHANGED
@@ -44,17 +44,36 @@ const ACTIONS: Partial<Record<KeymapId, KeyAction>> = {
44
44
  deleteLine: command('deleteLine'),
45
45
  moveLineUp: command('moveLineUp'),
46
46
  moveLineDown: command('moveLineDown'),
47
+ moveStatementUp: command('moveStatementUp'),
48
+ moveStatementDown: command('moveStatementDown'),
47
49
  toggleLineComment: command('toggleLineComment'),
48
50
  toggleBlockComment: command('toggleBlockComment'),
49
51
  joinLines: command('joinLines'),
50
52
  startNewLine: command('startNewLine'),
53
+ completeCurrentStatement: command('completeCurrentStatement'),
54
+ hippieCompletion: command('hippieCompletion'),
55
+ hippieBackwardCompletion: command('hippieBackwardCompletion'),
51
56
  startNewLineBefore: command('startNewLineBefore'),
52
57
  splitLine: command('splitLine'),
53
58
  toggleCase: command('toggleCase'),
54
59
  autoIndentLines: command('autoIndentLines'),
60
+ sortLines: command('sortLines'),
61
+ reverseLines: command('reverseLines'),
62
+ uniqueLines: command('uniqueLines'),
63
+ convertIndentsToSpaces: command('convertIndentsToSpaces'),
64
+ convertIndentsToTabs: command('convertIndentsToTabs'),
55
65
  expandSelection: command('expandSelection'),
56
66
  shrinkSelection: command('shrinkSelection'),
57
67
  matchBrace: { type: 'bracket' },
68
+ methodUp: view('methodUp'),
69
+ methodDown: view('methodDown'),
70
+ pasteAsPlainText: view('pasteAsPlainText'),
71
+ nextChange: view('nextChange'),
72
+ previousChange: view('previousChange'),
73
+ codeBlockStart: command('codeBlockStart'),
74
+ codeBlockEnd: command('codeBlockEnd'),
75
+ codeBlockStartWithSelection: command('codeBlockStartWithSelection'),
76
+ codeBlockEndWithSelection: command('codeBlockEndWithSelection'),
58
77
  selectNextOccurrence: command('selectNextOccurrence'),
59
78
  unselectOccurrence: command('unselectOccurrence'),
60
79
  selectAllOccurrences: command('selectAllOccurrences'),
@@ -87,7 +106,10 @@ const ACTIONS: Partial<Record<KeymapId, KeyAction>> = {
87
106
  expandAllToLevel3: view('expandAllToLevel3'),
88
107
  expandAllToLevel4: view('expandAllToLevel4'),
89
108
  expandAllToLevel5: view('expandAllToLevel5'),
90
- toggleColumnMode: view('toggleColumnMode')
109
+ toggleColumnMode: view('toggleColumnMode'),
110
+ increaseFontSize: view('increaseFontSize'),
111
+ decreaseFontSize: view('decreaseFontSize'),
112
+ resetFontSize: view('resetFontSize')
91
113
  };
92
114
 
93
115
  const PUNCTUATION_CODES: Readonly<Record<string, string>> = {
package/src/layout.ts CHANGED
@@ -57,8 +57,13 @@ export interface Inlay {
57
57
  composition?: boolean;
58
58
  /* A suggestion the host drew after the caret: text in the code face with no padding, which the caret stays in front of. */
59
59
  ghost?: boolean;
60
+ /* A square of this CSS color in place of the text, such as the color a literal in the code stands for. */
61
+ swatch?: string;
60
62
  }
61
63
 
64
+ /* The side of a swatch in pixels, without its gaps. */
65
+ export const SWATCH_SIZE = 12;
66
+
62
67
  /* A row of the host's own DOM above or below a line, as tall as the host makes it. */
63
68
  export interface BlockWidget {
64
69
  id: string;
@@ -366,10 +371,13 @@ export function scanDenseLine(
366
371
  const inlay = lineInlays[nextInlay++]!;
367
372
  const shown = visibleBidi(inlay.text);
368
373
 
374
+ const gaps = (inlay.gapLeft ?? 0) + (inlay.gapRight ?? 0);
369
375
  const width =
370
376
  inlay.composition || inlay.ghost
371
377
  ? metrics.measureText(shown)
372
- : Math.max(8, metrics.measureInlay(shown) + 12) + (inlay.gapLeft ?? 0) + (inlay.gapRight ?? 0);
378
+ : inlay.swatch !== undefined
379
+ ? SWATCH_SIZE + gaps
380
+ : Math.max(8, metrics.measureInlay(shown) + 12) + gaps;
373
381
 
374
382
  geometry.inlays.push({ inlay, x, width, subRow: row });
375
383
  x += width;
package/src/outline.ts CHANGED
@@ -215,6 +215,19 @@ export class Outline {
215
215
  this.version++;
216
216
  }
217
217
 
218
+ /* Where the named blocks of the structure declare their name, in no order: the stops of Method Up and Down. */
219
+ declarationStops(document: OutlineDocument): number[] {
220
+ return this.structure.flatMap((entry) => {
221
+ if (entry.name === undefined) {
222
+ return [];
223
+ }
224
+
225
+ const header = document.getLine(document.positionAt(entry.from).line);
226
+
227
+ return [header.start + Math.max(0, header.text.indexOf(entry.name))];
228
+ });
229
+ }
230
+
218
231
  /* Outermost first. Of two blocks that start on one line, only the larger one. */
219
232
  blocks(document: OutlineDocument): readonly OutlineBlock[] {
220
233
  if (this.cache?.version !== this.version) {
package/src/overview.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type OverviewKind = 'added' | 'modified' | 'deleted' | 'find' | 'find-current' | 'occurrence' | 'info' | 'warning' | 'error';
1
+ export type OverviewKind = 'added' | 'modified' | 'deleted' | 'find' | 'find-current' | 'occurrence' | 'todo' | 'info' | 'warning' | 'error';
2
2
 
3
3
  /* A stretch of the content, in the pixels the content is laid out in. */
4
4
  export interface OverviewSpan {
package/src/paint.ts CHANGED
@@ -186,11 +186,20 @@ export function lineElement(
186
186
  }
187
187
 
188
188
  const span = document.createElement('span');
189
- span.className = box.inlay.ghost ? 'se-ghost' : box.inlay.composition ? 'se-composition' : 'se-inlay';
189
+ span.className = box.inlay.ghost
190
+ ? 'se-ghost'
191
+ : box.inlay.composition
192
+ ? 'se-composition'
193
+ : box.inlay.swatch !== undefined
194
+ ? 'se-inlay se-swatch'
195
+ : 'se-inlay';
190
196
  span.dataset.inlayId = box.inlay.id;
191
197
  const { parts } = box.inlay;
192
198
 
193
- if (parts === undefined) {
199
+ if (box.inlay.swatch !== undefined) {
200
+ span.style.backgroundColor = box.inlay.swatch;
201
+ span.setAttribute('aria-label', box.inlay.text);
202
+ } else if (parts === undefined) {
194
203
  span.textContent = visibleBidi(box.inlay.text);
195
204
  } else {
196
205
  parts.forEach((part, index) => {
package/src/todos.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { DEFAULT_TODO_WORDS, type DocumentModel, type TodoMatch } from '@adecore/editor-core';
2
+ import { overlayTokens } from './semantic.ts';
3
+ import type { LineToken } from './types.ts';
4
+
5
+ const TODO_COLOR = 'var(--editor-todo)';
6
+ const ITALIC = 1;
7
+ /* The marks in the scroll track read the whole document, so they wait for a pause in editing as the folds do. */
8
+ const TODO_DELAY_MS = 300;
9
+ const TODO_LIMIT = 2_000_000;
10
+ const CACHED_LINES = 600;
11
+
12
+ /* The text is kept with the mark, since the list is only read again after a pause and the offsets may be stale until then. */
13
+ export type TodoMark = TodoMatch & { readonly text: string };
14
+
15
+ /*
16
+ * The TODO comments of a view: drawn in their own color over the grammar's on the lines being painted,
17
+ * and listed for the whole document a moment after the last edit, for the ticks in the scroll track.
18
+ */
19
+ export class TodoMarks {
20
+ private readonly model: DocumentModel;
21
+ private readonly changed: () => void;
22
+ private words: readonly string[] = DEFAULT_TODO_WORDS;
23
+ /* Empty until the first `configure`, which reads the document. */
24
+ private language = '';
25
+ private revision = -1;
26
+ private readonly lines = new Map<number, { base: readonly LineToken[] | null; result: readonly LineToken[] | null }>();
27
+ private timer: ReturnType<typeof setTimeout> | undefined;
28
+ private all: readonly TodoMark[] = [];
29
+ /* Counts every new list, so a painter can tell its ticks are stale. */
30
+ version = 0;
31
+
32
+ constructor(model: DocumentModel, changed: () => void) {
33
+ this.model = model;
34
+ this.changed = changed;
35
+ }
36
+
37
+ get marks(): readonly TodoMark[] {
38
+ return this.all;
39
+ }
40
+
41
+ configure(language: string | undefined, words: readonly string[] | undefined): void {
42
+ const nextLanguage = language ?? 'plaintext';
43
+ const nextWords = words ?? DEFAULT_TODO_WORDS;
44
+
45
+ if (nextLanguage === this.language && nextWords.join('\n') === this.words.join('\n')) {
46
+ return;
47
+ }
48
+
49
+ this.language = nextLanguage;
50
+ this.words = nextWords;
51
+ this.revision = -1;
52
+ this.refresh();
53
+ }
54
+
55
+ /* The line's tokens with its TODOs drawn over them, the same array for as long as neither changed. */
56
+ styled(line: number, base: readonly LineToken[] | null): readonly LineToken[] | null {
57
+ if (this.words.length === 0) {
58
+ return base;
59
+ }
60
+
61
+ const revision = this.model.getRevision();
62
+
63
+ if (revision !== this.revision || this.lines.size > CACHED_LINES) {
64
+ this.revision = revision;
65
+ this.lines.clear();
66
+ }
67
+
68
+ const cached = this.lines.get(line);
69
+
70
+ if (cached !== undefined && cached.base === base) {
71
+ return cached.result;
72
+ }
73
+
74
+ const matches = this.model.findTodos(this.language, { words: this.words }, line, line);
75
+ let result = base;
76
+
77
+ if (matches.length > 0) {
78
+ const { start, end } = this.model.getLine(line);
79
+ const spans = matches.map((match) => ({ from: match.from - start, to: match.to - start, color: TODO_COLOR, fontStyle: ITALIC }));
80
+ result = overlayTokens(base ?? [{ length: end - start, color: '', fontStyle: 0 }], spans);
81
+ }
82
+
83
+ this.lines.set(line, { base, result });
84
+
85
+ return result;
86
+ }
87
+
88
+ schedule(): void {
89
+ clearTimeout(this.timer);
90
+ this.timer = setTimeout(() => this.refresh(), TODO_DELAY_MS);
91
+ }
92
+
93
+ /* Reads the TODOs of the whole document now, which otherwise waits for a pause in editing. */
94
+ refresh(): void {
95
+ clearTimeout(this.timer);
96
+ const found = this.words.length === 0 || this.model.getLength() > TODO_LIMIT ? [] : this.model.findTodos(this.language, { words: this.words });
97
+ const next = found.map((match) => ({ ...match, text: this.model.slice(match.from, match.to) }));
98
+
99
+ if (next.length === 0 && this.all.length === 0) {
100
+ return;
101
+ }
102
+
103
+ this.all = next;
104
+ this.version++;
105
+ this.changed();
106
+ }
107
+
108
+ dispose(): void {
109
+ clearTimeout(this.timer);
110
+ }
111
+ }