| import { |
| Prec, |
| StateEffect, |
| StateField, |
| Transaction, |
| type EditorState, |
| type Extension, |
| } from "@codemirror/state"; |
| import { |
| Decoration, |
| EditorView, |
| keymap, |
| ViewPlugin, |
| WidgetType, |
| type PluginValue, |
| type ViewUpdate, |
| } from "@codemirror/view"; |
| import { requestCompletion, type CompletionDeps } from "./provider"; |
|
|
| export type AutocompletePrefs = CompletionDeps & { |
| enabled: boolean; |
| }; |
|
|
| export type AutocompleteContext = { |
| getPrefs: () => AutocompletePrefs; |
| getPath: () => string | null; |
| getLanguage: () => string | null; |
| }; |
|
|
| type Suggestion = { |
| from: number; |
| text: string; |
| }; |
|
|
| const setSuggestion = StateEffect.define<Suggestion | null>(); |
|
|
| const suggestionField = StateField.define<Suggestion | null>({ |
| create: () => null, |
| update(value, tr) { |
| for (const e of tr.effects) { |
| if (e.is(setSuggestion)) return e.value; |
| } |
| if (!value) return value; |
| if (tr.docChanged) { |
| return consumeIfTypedAhead(value, tr); |
| } |
| if (tr.selection) return null; |
| return value; |
| }, |
| }); |
|
|
| function consumeIfTypedAhead( |
| current: Suggestion, |
| tr: Transaction, |
| ): Suggestion | null { |
| let consumed: string | null = null; |
| let originDelta = 0; |
| let abort = false; |
| tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { |
| if (abort) return; |
| const ins = inserted.toString(); |
| if (fromA !== toA || fromA !== current.from || !ins) { |
| abort = true; |
| return; |
| } |
| if (current.text.startsWith(ins)) { |
| consumed = ins; |
| originDelta = ins.length; |
| } else { |
| abort = true; |
| } |
| }); |
| if (abort || !consumed) return null; |
| const remaining = current.text.slice((consumed as string).length); |
| if (!remaining) return null; |
| return { from: current.from + originDelta, text: remaining }; |
| } |
|
|
| class GhostWidget extends WidgetType { |
| constructor(readonly text: string) { |
| super(); |
| } |
| override eq(other: GhostWidget): boolean { |
| return other.text === this.text; |
| } |
| toDOM(): HTMLElement { |
| const span = document.createElement("span"); |
| span.className = "cm-ai-ghost"; |
| const lines = this.text.split("\n"); |
| lines.forEach((line, i) => { |
| if (i > 0) span.appendChild(document.createElement("br")); |
| span.appendChild(document.createTextNode(line)); |
| }); |
| return span; |
| } |
| override ignoreEvent(): boolean { |
| return true; |
| } |
| } |
|
|
| const ghostTheme = EditorView.theme({ |
| ".cm-ai-ghost": { |
| opacity: "0.45", |
| fontStyle: "italic", |
| pointerEvents: "none", |
| }, |
| }); |
|
|
| const ghostDecorations = EditorView.decorations.compute( |
| [suggestionField], |
| (state) => { |
| const sug = state.field(suggestionField); |
| if (!sug) return Decoration.none; |
| return Decoration.set([ |
| Decoration.widget({ |
| widget: new GhostWidget(sug.text), |
| side: 1, |
| }).range(sug.from), |
| ]); |
| }, |
| ); |
|
|
| const DEBOUNCE_MS = 350; |
| const CHAIN_DELAY_MS = 80; |
| const MIN_PREFIX_CHARS = 2; |
| const MAX_LINES = 6; |
| const CACHE_SIZE = 32; |
| const CACHE_TAIL = 512; |
| const CACHE_HEAD = 128; |
| const PREFIX_WINDOW = 4000; |
| const SUFFIX_WINDOW = 2000; |
|
|
| class LRU<K, V> { |
| private map = new Map<K, V>(); |
| constructor(private readonly cap: number) {} |
| get(k: K): V | undefined { |
| const v = this.map.get(k); |
| if (v === undefined) return undefined; |
| this.map.delete(k); |
| this.map.set(k, v); |
| return v; |
| } |
| set(k: K, v: V) { |
| if (this.map.has(k)) this.map.delete(k); |
| this.map.set(k, v); |
| if (this.map.size > this.cap) { |
| const first = this.map.keys().next().value; |
| if (first !== undefined) this.map.delete(first); |
| } |
| } |
| clear() { |
| this.map.clear(); |
| } |
| } |
|
|
| function suggestionKey(prefix: string, suffix: string, lang: string | null): string { |
| const p = prefix.length > CACHE_TAIL ? prefix.slice(-CACHE_TAIL) : prefix; |
| const s = suffix.length > CACHE_HEAD ? suffix.slice(0, CACHE_HEAD) : suffix; |
| return `${lang ?? ""}${p} |