diff --git a/apps/vscode/src/providers/editor/codeview.ts b/apps/vscode/src/providers/editor/codeview.ts index e4280ce6..a05d0282 100644 --- a/apps/vscode/src/providers/editor/codeview.ts +++ b/apps/vscode/src/providers/editor/codeview.ts @@ -102,16 +102,6 @@ export function vscodeCodeViewServer(_engine: MarkdownEngine, document: TextDocu return lspCellYamlOptionsCompletions(context, lspRequest); } - // if this is Positron, no visual editor completions - // TODO: fix LSP issues for visual editor in Positron: - // https://github.com/posit-dev/positron/issues/1805 - if (hasHooks()) { - return { - items: [], - isIncomplete: false - }; - } - // otherwise delegate to vscode completion system const vdoc = virtualDocForCode(context.code, language); const completions = await vdocCompletions( diff --git a/packages/editor-codemirror/src/behaviors/completion.ts b/packages/editor-codemirror/src/behaviors/completion.ts index b998d04a..753eb112 100644 --- a/packages/editor-codemirror/src/behaviors/completion.ts +++ b/packages/editor-codemirror/src/behaviors/completion.ts @@ -102,135 +102,160 @@ export function completionBehavior(behaviorContext: BehaviorContext): Behavior { }; } -async function getCompletions( - context: CompletionContext, - cvContext: CodeViewCompletionContext, - behaviorContext: BehaviorContext -): Promise { - - // get completions - const completions = await behaviorContext.pmContext.ui.codeview?.codeViewCompletions(cvContext); - if (context.aborted || !completions || completions.items.length == 0) { - return null; +const compareBySortText = (a: CompletionItem, b: CompletionItem) => { + if (a.sortText && b.sortText) { + return a.sortText.localeCompare(b.sortText); + } else { + return 0; } +}; - // order completions - const haveOrder = !!completions.items?.[0].sortText; - if (haveOrder) { - completions.items = completions.items.sort((a, b) => { - if (a.sortText && b.sortText) { - return a.sortText.localeCompare(b.sortText); - } else { - return 0; +// compute from +const itemFrom = (item: CompletionItem, contextPos: number) => { + // compute from + return item.textEdit + ? InsertReplaceEdit.is(item.textEdit) + ? contextPos - (item.textEdit.insert.end.character - item.textEdit.insert.start.character) + : TextEdit.is(item.textEdit) + ? contextPos - (item.textEdit.range.end.character - item.textEdit.range.start.character) + : contextPos + : contextPos; +}; + +/** + * replaceText for a given CompletionItem is the text that is already in the document + * that that CompletionItem will replace. + * + * Example 1: if you are typing `lib` and get the completion `library`, then this function + * will give `lib`. + * Example 2: if you are typing `os.a` and get the completion `abc`, then this function + * will give `a`. + */ +const getReplaceText = (context: CompletionContext, item: CompletionItem) => + context.state.sliceDoc(itemFrom(item, context.pos), context.pos); + +const makeCompletionItemApplier = (item: CompletionItem, context: CompletionContext) => + (view: EditorView, completion: Completion) => { + // compute from + const from = itemFrom(item, context.pos); + + // handle snippets + const insertText = item.textEdit?.newText ?? (item.insertText || item.label); + if (item.insertTextFormat === InsertTextFormat.Snippet) { + const insertSnippet = snippet(insertText.replace(/\$(\d+)/g, "$${$1}")); + insertSnippet(view, completion, from, context.pos); + // normal completions + } else { + view.dispatch({ + ...insertCompletionText(view.state, insertText, from, context.pos), + annotations: pickedCompletion.of(completion) + }); + if (item.command?.command === "editor.action.triggerSuggest") { + startCompletion(view); } - }); + } + }; + +const sortTextItemsBoostScore = (context: CompletionContext, items: CompletionItem[], index: number) => { + const total = items.length; + const item = items[index]; + // compute replaceText + const replaceText = getReplaceText(context, item); + + // if the replaceText doesn't start with "." then bury items that do + if (!replaceText.startsWith(".") && item.label.startsWith(".")) { + return -99; } - // compute token - const token = context.matchBefore(/\S+/)?.text; + // only boost things that have a prefix match + if (item.label.toLowerCase().startsWith(replaceText) || + (item.textEdit && item.textEdit.newText.toLowerCase().startsWith(replaceText)) || + (item.insertText && item.insertText.toLowerCase().startsWith(replaceText))) { + return -99 + Math.round(((total - index) / total) * 198);; + } else { + return -99; + } +}; - // compute from - const itemFrom = (item: CompletionItem) => { - // compute from - return item.textEdit - ? InsertReplaceEdit.is(item.textEdit) - ? context.pos - (item.textEdit.insert.end.character - item.textEdit.insert.start.character) - : TextEdit.is(item.textEdit) - ? context.pos - (item.textEdit.range.end.character - item.textEdit.range.start.character) - : context.pos - : context.pos; - }; +const defaultBoostScore = (context: CompletionContext, items: CompletionItem[], index: number) => { + const item = items[index]; - // use order to create boost - const total = completions.items.length; - const boostScore = (index: number) => { + const replaceText = getReplaceText(context, item); - // compute replaceText - const item = completions.items[index]; - const replaceText = context.state.sliceDoc(itemFrom(item), context.pos).toLowerCase(); + // if you haven't typed into the completions yet (for example after a `.`) then + // score items starting with non-alphabetic characters -1, everything else 0. + if (replaceText.length === 0) return isLetter(item.label[0]) ? 0 : -1; - if (haveOrder) { + // We filter items by replaceText inclusion before scoring, + // so i is garaunteed to be an index into `item.label`... + const i = item.label.toLowerCase().indexOf(replaceText.toLowerCase()); + // and `replaceTextInItermLabel` should be the same as `replaceText` up to upper/lowercase + // differences. + const replaceTextInItemLabel = item.label.slice(i, replaceText.length); - // if the replaceText doesn't start with "." then bury items that do - if (!replaceText.startsWith(".") && item.label.startsWith(".")) { - return -99; - } + // mostly counts how many upper/lowercase differences there are + let diff = simpleStringDiff(replaceTextInItemLabel, replaceText); - // only boost things that have a prefix match - if (item.label.toLowerCase().startsWith(replaceText) || - (item.textEdit && item.textEdit.newText.toLowerCase().startsWith(replaceText)) || - (item.insertText && item.insertText.toLowerCase().startsWith(replaceText))) { - return -99 + Math.round(((total - index) / total) * 198);; - } else { - return -99; - } + // `-i` scores completions better if what you typed is earlier in the completion + // `-diff/10` mostly tie breaks that score by capitalization differences. + return -i - diff / 10; // 10 is a magic number +}; - } else { - return undefined; - } - }; +async function getCompletions( + context: CompletionContext, + cvContext: CodeViewCompletionContext, + behaviorContext: BehaviorContext +): Promise { + if (context.aborted) return null; - // return completions - return { - from: context.pos, + // get completions + const completions = await behaviorContext.pmContext.ui.codeview?.codeViewCompletions(cvContext); + if (completions === undefined) return null; + if (completions.items.length == 0) return null; - options: completions.items - .filter(item => { + const itemsHaveSortText = completions.items?.[0].sortText !== undefined; - // no text completions that aren't snippets - if (item.kind === CompletionItemKind.Text && - item.insertTextFormat !== InsertTextFormat.Snippet) { - return false; - } + const items = itemsHaveSortText ? + completions.items.sort(compareBySortText) : + completions.items; - // compute text to replace - const replaceText = context.state.sliceDoc(itemFrom(item), context.pos).toLowerCase(); + // The token is the contents of the line up to your cursor. + // For example, if you type `os.a` then token will be `os.a`. + // Note: in contrast, when you type `os.a` replaceText will give `a` for a completion like `abc`. + const token = context.matchBefore(/\S+/)?.text; - // only allow non-text edits if we have no token - if (!item.textEdit && token) { - return false; - } + const filteredItems = items.filter(item => { + // no text completions that aren't snippets + if (item.kind === CompletionItemKind.Text && + item.insertTextFormat !== InsertTextFormat.Snippet) return false; + + // only allow non-text edits if we have no token + if (item.textEdit === undefined && token) return false; + + // require at least inclusion + const replaceText = getReplaceText(context, item).toLowerCase(); + return item.label.toLowerCase().includes(replaceText) || + item.insertText?.toLowerCase().includes(replaceText); + }); + + const boostScore = itemsHaveSortText ? + sortTextItemsBoostScore : + defaultBoostScore; + + const options = filteredItems + .map((item, index): Completion => { + return { + label: item.label, + detail: !item.documentation ? item.detail : undefined, + type: vsKindToType(item.kind), + info: () => infoNodeForItem(item), + apply: makeCompletionItemApplier(item, context), + boost: boostScore(context, filteredItems, index) + }; + }); - // require at least inclusion - return item.label.toLowerCase().includes(replaceText) || - (item.insertText && item.insertText.toLowerCase().includes(replaceText)); - }) - .map((item, index): Completion => { - return { - label: item.label, - detail: item.detail && !item.documentation ? item.detail : undefined, - type: vsKindToType(item.kind), - info: (): Node | null => { - if (item.documentation) { - return infoNodeForItem(item); - } else { - return null; - } - }, - apply: (view: EditorView, completion: Completion, from: number) => { - // compute from - from = itemFrom(item); - - // handle snippets - const insertText = item.textEdit?.newText ?? (item.insertText || item.label); - if (item.insertTextFormat === InsertTextFormat.Snippet) { - const insertSnippet = snippet(insertText.replace(/\$(\d+)/g, "$${$1}")); - insertSnippet(view, completion, from, context.pos); - // normal completions - } else { - view.dispatch({ - ...insertCompletionText(view.state, insertText, from, context.pos), - annotations: pickedCompletion.of(completion) - }); - if (item.command?.command === "editor.action.triggerSuggest") { - startCompletion(view); - } - } - }, - boost: boostScore(index) - }; - }) - }; + // return completions + return { from: context.pos, options }; } @@ -281,7 +306,6 @@ function vsKindToType(kind?: CompletionItemKind) { function infoNodeForItem(item: CompletionItem) { - const headerEl = (text: string, tag: string) => { const header = document.createElement(tag); header.classList.add("cm-completionInfoHeader"); @@ -328,3 +352,15 @@ function infoNodeForItem(item: CompletionItem) { return null; } } + +function simpleStringDiff(str1: string, str2: string) { + let diff = 0; + for (let i = 0; i < Math.min(str1.length, str2.length); i++) { + if (str1[i] !== str2[i]) diff++; + } + return diff; +}; + +function isLetter(c: string) { + return c.toLowerCase() != c.toUpperCase(); +} diff --git a/packages/editor-codemirror/src/behaviors/indent.ts b/packages/editor-codemirror/src/behaviors/indent.ts index 8c5eeb8e..caf7f283 100644 --- a/packages/editor-codemirror/src/behaviors/indent.ts +++ b/packages/editor-codemirror/src/behaviors/indent.ts @@ -13,18 +13,29 @@ * */ -import { indentWithTab } from "@codemirror/commands"; +import { indentLess, indentMore } from "@codemirror/commands"; import { indentOnInput } from "@codemirror/language"; import { keymap } from "@codemirror/view"; import { Behavior } from "."; +import { acceptCompletion, completionStatus } from "@codemirror/autocomplete"; -export function indentBehavior() : Behavior { +export function tabBehavior(): Behavior { return { extensions: [ indentOnInput(), - keymap.of([indentWithTab]) + keymap.of([ + { + key: 'Tab', + preventDefault: true, + shift: indentLess, + run: e => { + if (!completionStatus(e.state)) return indentMore(e); + return acceptCompletion(e); + }, + }, + ]) ] - } + }; } diff --git a/packages/editor-codemirror/src/behaviors/index.ts b/packages/editor-codemirror/src/behaviors/index.ts index dae5a544..8b668625 100644 --- a/packages/editor-codemirror/src/behaviors/index.ts +++ b/packages/editor-codemirror/src/behaviors/index.ts @@ -27,7 +27,7 @@ import { CodeViewOptions, ExtensionContext } from "editor"; import { langModeBehavior } from './langmode'; import { keyboardBehavior } from './keyboard'; import { findBehavior } from './find'; -import { indentBehavior } from './indent'; +import { tabBehavior } from './indent'; import { trackSelectionBehavior } from './trackselection'; import { themeBehavior } from './theme'; import { prefsBehavior } from './prefs'; @@ -63,7 +63,7 @@ export function createBehaviors(context: BehaviorContext): Behavior[] { langModeBehavior(context), completionBehavior(context), findBehavior(context), - indentBehavior(), + tabBehavior(), themeBehavior(context), prefsBehavior(context), trackSelectionBehavior(context),