Code mirror plugin lsp interface (#1444)
* better named dirs Signed-off-by: Jess Frazelle <github@jessfraz.com> * move some stuff around Signed-off-by: Jess Frazelle <github@jessfraz.com> * more logging Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * less logging Signed-off-by: Jess Frazelle <github@jessfraz.com> * add fs in Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * file reader Signed-off-by: Jess Frazelle <github@jessfraz.com> * workspace Signed-off-by: Jess Frazelle <github@jessfraz.com> * start of workspace folders Signed-off-by: Jess Frazelle <github@jessfraz.com> * start of workspace folders Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup workspace folders Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup logs Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> --------- Signed-off-by: Jess Frazelle <github@jessfraz.com>
This commit is contained in:
496
src/editor/plugins/lsp/copilot/index.ts
Normal file
496
src/editor/plugins/lsp/copilot/index.ts
Normal file
@ -0,0 +1,496 @@
|
||||
/// Thanks to the Cursor folks for their heavy lifting here.
|
||||
import { indentUnit } from '@codemirror/language'
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
Annotation,
|
||||
EditorState,
|
||||
Extension,
|
||||
Prec,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Transaction,
|
||||
} from '@codemirror/state'
|
||||
import { completionStatus } from '@codemirror/autocomplete'
|
||||
import { offsetToPos, posToOffset } from 'editor/plugins/lsp/util'
|
||||
import { LanguageServerOptions, LanguageServerClient } from 'editor/plugins/lsp'
|
||||
import {
|
||||
LanguageServerPlugin,
|
||||
documentUri,
|
||||
languageId,
|
||||
workspaceFolders,
|
||||
} from 'editor/plugins/lsp/plugin'
|
||||
|
||||
const ghostMark = Decoration.mark({ class: 'cm-ghostText' })
|
||||
|
||||
interface Suggestion {
|
||||
text: string
|
||||
displayText: string
|
||||
cursorPos: number
|
||||
startPos: number
|
||||
endPos: number
|
||||
endReplacement: number
|
||||
uuid: string
|
||||
}
|
||||
|
||||
// Effects to tell StateEffect what to do with GhostText
|
||||
const addSuggestion = StateEffect.define<Suggestion>()
|
||||
const acceptSuggestion = StateEffect.define<null>()
|
||||
const clearSuggestion = StateEffect.define<null>()
|
||||
const typeFirst = StateEffect.define<number>()
|
||||
|
||||
interface CompletionState {
|
||||
ghostText: GhostText | null
|
||||
}
|
||||
interface GhostText {
|
||||
text: string
|
||||
displayText: string
|
||||
displayPos: number
|
||||
startPos: number
|
||||
endGhostText: number
|
||||
endReplacement: number
|
||||
endPos: number
|
||||
decorations: DecorationSet
|
||||
weirdInsert: boolean
|
||||
uuid: string
|
||||
}
|
||||
|
||||
export const completionDecoration = StateField.define<CompletionState>({
|
||||
create(_state: EditorState) {
|
||||
return { ghostText: null }
|
||||
},
|
||||
update(state: CompletionState, transaction: Transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(addSuggestion)) {
|
||||
// When adding a suggestion, we set th ghostText
|
||||
const {
|
||||
text,
|
||||
displayText,
|
||||
endReplacement,
|
||||
cursorPos,
|
||||
startPos,
|
||||
endPos,
|
||||
uuid,
|
||||
} = effect.value
|
||||
const endGhostText = cursorPos + displayText.length
|
||||
const decorations = Decoration.set([
|
||||
ghostMark.range(cursorPos, endGhostText),
|
||||
])
|
||||
return {
|
||||
ghostText: {
|
||||
text,
|
||||
displayText,
|
||||
startPos,
|
||||
endPos,
|
||||
decorations,
|
||||
displayPos: cursorPos,
|
||||
endReplacement,
|
||||
endGhostText,
|
||||
weirdInsert: false,
|
||||
uuid,
|
||||
},
|
||||
}
|
||||
} else if (effect.is(acceptSuggestion)) {
|
||||
if (state.ghostText) {
|
||||
return { ghostText: null }
|
||||
}
|
||||
} else if (effect.is(typeFirst)) {
|
||||
const numChars = effect.value
|
||||
if (state.ghostText && !state.ghostText.weirdInsert) {
|
||||
let {
|
||||
text,
|
||||
displayText,
|
||||
displayPos,
|
||||
startPos,
|
||||
endPos,
|
||||
endGhostText,
|
||||
decorations,
|
||||
endReplacement,
|
||||
uuid,
|
||||
} = state.ghostText
|
||||
|
||||
displayPos += numChars
|
||||
|
||||
displayText = displayText.slice(numChars)
|
||||
|
||||
if (startPos === endGhostText) {
|
||||
return { ghostText: null }
|
||||
} else {
|
||||
decorations = Decoration.set([
|
||||
ghostMark.range(displayPos, endGhostText),
|
||||
])
|
||||
return {
|
||||
ghostText: {
|
||||
text,
|
||||
displayText,
|
||||
startPos,
|
||||
endPos,
|
||||
decorations,
|
||||
endGhostText,
|
||||
endReplacement,
|
||||
uuid,
|
||||
displayPos,
|
||||
weirdInsert: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (effect.is(clearSuggestion)) {
|
||||
return { ghostText: null }
|
||||
}
|
||||
}
|
||||
|
||||
// if (transaction.docChanged && state.ghostText) {
|
||||
// if (transaction.
|
||||
// onsole.log({changes: transaction.changes, transaction})
|
||||
// const newGhostText = state.ghostText.decorations.map(transaction.changes)
|
||||
// return {ghostText: {...state.ghostText, decorations: newGhostText}};
|
||||
// }
|
||||
|
||||
return state
|
||||
},
|
||||
provide: (field) =>
|
||||
EditorView.decorations.from(field, (value) =>
|
||||
value.ghostText ? value.ghostText.decorations : Decoration.none
|
||||
),
|
||||
})
|
||||
|
||||
const copilotEvent = Annotation.define<null>()
|
||||
|
||||
/****************************************************************************
|
||||
************************* COMMANDS ******************************************
|
||||
*****************************************************************************/
|
||||
|
||||
const acceptSuggestionCommand = (
|
||||
copilotClient: LanguageServerClient,
|
||||
view: EditorView
|
||||
) => {
|
||||
// We delete the ghost text and insert the suggestion.
|
||||
// We also set the cursor to the end of the suggestion.
|
||||
const ghostText = view.state.field(completionDecoration)!.ghostText
|
||||
if (!ghostText) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const ghostTextEnd = ghostText.endGhostText
|
||||
|
||||
const actualTextStart = ghostText.startPos
|
||||
const actualTextEnd = ghostText.endPos
|
||||
|
||||
const replacementEnd = ghostText.endReplacement
|
||||
|
||||
const suggestion = ghostText.text
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: ghostTextStart,
|
||||
to: ghostTextEnd,
|
||||
insert: '',
|
||||
},
|
||||
// selection: {anchor: actualTextEnd},
|
||||
effects: acceptSuggestion.of(null),
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
const tmpTextEnd = replacementEnd - (ghostTextEnd - ghostTextStart)
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: actualTextStart,
|
||||
to: tmpTextEnd,
|
||||
insert: suggestion,
|
||||
},
|
||||
selection: { anchor: actualTextEnd },
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(true)],
|
||||
})
|
||||
|
||||
copilotClient.accept(ghostText.uuid)
|
||||
return true
|
||||
}
|
||||
export const rejectSuggestionCommand = (
|
||||
copilotClient: LanguageServerClient,
|
||||
view: EditorView
|
||||
) => {
|
||||
// We delete the suggestion, then carry through with the original keypress
|
||||
const ghostText = view.state.field(completionDecoration)!.ghostText
|
||||
if (!ghostText) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const ghostTextEnd = ghostText.endGhostText
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: ghostTextStart,
|
||||
to: ghostTextEnd,
|
||||
insert: '',
|
||||
},
|
||||
effects: clearSuggestion.of(null),
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
copilotClient.reject()
|
||||
return false
|
||||
}
|
||||
|
||||
const sameKeyCommand = (
|
||||
copilotClient: LanguageServerClient,
|
||||
view: EditorView,
|
||||
key: string
|
||||
) => {
|
||||
// When we type a key that is the same as the first letter of the suggestion, we delete the first letter of the suggestion and carry through with the original keypress
|
||||
const ghostText = view.state.field(completionDecoration)!.ghostText
|
||||
if (!ghostText) {
|
||||
return false
|
||||
}
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const indent = view.state.facet(indentUnit)
|
||||
|
||||
if (key === 'Tab' && ghostText.displayText.startsWith(indent)) {
|
||||
view.dispatch({
|
||||
selection: { anchor: ghostTextStart + indent.length },
|
||||
effects: typeFirst.of(indent.length),
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
|
||||
})
|
||||
return true
|
||||
} else if (key === 'Tab') {
|
||||
return acceptSuggestionCommand(copilotClient, view)
|
||||
} else if (ghostText.weirdInsert || key !== ghostText.displayText[0]) {
|
||||
return rejectSuggestionCommand(copilotClient, view)
|
||||
} else if (ghostText.displayText.length === 1) {
|
||||
return acceptSuggestionCommand(copilotClient, view)
|
||||
} else {
|
||||
// Use this to delete the first letter of the suggestion
|
||||
view.dispatch({
|
||||
selection: { anchor: ghostTextStart + 1 },
|
||||
effects: typeFirst.of(1),
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const completionPlugin = (copilotClient: LanguageServerClient) =>
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event, view) {
|
||||
if (
|
||||
event.key !== 'Shift' &&
|
||||
event.key !== 'Control' &&
|
||||
event.key !== 'Alt' &&
|
||||
event.key !== 'Meta'
|
||||
) {
|
||||
return sameKeyCommand(copilotClient, view, event.key)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
mousedown(event, view) {
|
||||
return rejectSuggestionCommand(copilotClient, view)
|
||||
},
|
||||
})
|
||||
|
||||
const viewCompletionPlugin = (copilotClient: LanguageServerClient) =>
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.focusChanged) {
|
||||
rejectSuggestionCommand(copilotClient, update.view)
|
||||
}
|
||||
})
|
||||
// A view plugin that requests completions from the server after a delay
|
||||
const completionRequester = (client: LanguageServerClient) => {
|
||||
let timeout: any = null
|
||||
let lastPos = 0
|
||||
|
||||
const badUpdate = (update: ViewUpdate) => {
|
||||
for (const tr of update.transactions) {
|
||||
if (tr.annotation(copilotEvent) !== undefined) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const containsGhostText = (update: ViewUpdate) => {
|
||||
return update.state.field(completionDecoration).ghostText != null
|
||||
}
|
||||
const autocompleting = (update: ViewUpdate) => {
|
||||
return completionStatus(update.state) === 'active'
|
||||
}
|
||||
const notFocused = (update: ViewUpdate) => {
|
||||
return !update.view.hasFocus
|
||||
}
|
||||
|
||||
return EditorView.updateListener.of((update: ViewUpdate) => {
|
||||
if (
|
||||
update.docChanged &&
|
||||
!update.transactions.some((tr) =>
|
||||
tr.effects.some((e) => e.is(acceptSuggestion) || e.is(clearSuggestion))
|
||||
)
|
||||
) {
|
||||
// Cancel the previous timeout
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (
|
||||
badUpdate(update) ||
|
||||
containsGhostText(update) ||
|
||||
autocompleting(update) ||
|
||||
notFocused(update)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current position and source
|
||||
const state = update.state
|
||||
const pos = state.selection.main.head
|
||||
const source = state.doc.toString()
|
||||
|
||||
const dUri = state.facet(documentUri)
|
||||
const path = dUri.split('/').pop()!
|
||||
const relativePath = dUri.replace('file://', '')
|
||||
|
||||
// Set a new timeout to request completion
|
||||
timeout = setTimeout(async () => {
|
||||
// Check if the position has changed
|
||||
if (pos === lastPos) {
|
||||
// Request completion from the server
|
||||
try {
|
||||
const completionResult = await client.getCompletion({
|
||||
doc: {
|
||||
source,
|
||||
tabSize: state.facet(EditorState.tabSize),
|
||||
indentSize: 1,
|
||||
insertSpaces: true,
|
||||
path,
|
||||
uri: dUri,
|
||||
relativePath,
|
||||
languageId: state.facet(languageId),
|
||||
position: offsetToPos(state.doc, pos),
|
||||
},
|
||||
})
|
||||
|
||||
if (completionResult.completions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let {
|
||||
text,
|
||||
displayText,
|
||||
range: { start },
|
||||
position,
|
||||
uuid,
|
||||
} = completionResult.completions[0]
|
||||
|
||||
const startPos = posToOffset(state.doc, {
|
||||
line: start.line,
|
||||
character: start.character,
|
||||
})!
|
||||
|
||||
const endGhostPos =
|
||||
posToOffset(state.doc, {
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
})! + displayText.length
|
||||
// EndPos is the position that marks the complete end
|
||||
// of what is to be replaced when we accept a completion
|
||||
// result
|
||||
const endPos = startPos + text.length
|
||||
|
||||
// Check if the position is still the same
|
||||
if (
|
||||
pos === lastPos &&
|
||||
completionStatus(update.view.state) !== 'active' &&
|
||||
update.view.hasFocus
|
||||
) {
|
||||
// Dispatch an effect to add the suggestion
|
||||
// If the completion starts before the end of the line, check the end of the line with the end of the completion
|
||||
const line = update.view.state.doc.lineAt(pos)
|
||||
if (line.to !== pos) {
|
||||
const ending = update.view.state.doc.sliceString(pos, line.to)
|
||||
if (displayText.endsWith(ending)) {
|
||||
displayText = displayText.slice(
|
||||
0,
|
||||
displayText.length - ending.length
|
||||
)
|
||||
} else if (displayText.includes(ending)) {
|
||||
// Remove the ending
|
||||
update.view.dispatch({
|
||||
changes: {
|
||||
from: pos,
|
||||
to: line.to,
|
||||
insert: '',
|
||||
},
|
||||
selection: { anchor: pos },
|
||||
effects: typeFirst.of(ending.length),
|
||||
annotations: [
|
||||
copilotEvent.of(null),
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
update.view.dispatch({
|
||||
changes: {
|
||||
from: pos,
|
||||
to: pos,
|
||||
insert: displayText,
|
||||
},
|
||||
effects: [
|
||||
addSuggestion.of({
|
||||
displayText,
|
||||
endReplacement: endGhostPos,
|
||||
text,
|
||||
cursorPos: pos,
|
||||
startPos,
|
||||
endPos,
|
||||
uuid,
|
||||
}),
|
||||
],
|
||||
annotations: [
|
||||
copilotEvent.of(null),
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('copilot completion failed', error)
|
||||
// Javascript wait for 500ms for some reason is necessary here.
|
||||
// TODO - FIGURE OUT WHY THIS RESOLVES THE BUG
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
}
|
||||
}, 150)
|
||||
// Update the last position
|
||||
lastPos = pos
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const copilotPlugin = (options: LanguageServerOptions): Extension => {
|
||||
let plugin: LanguageServerPlugin | null = null
|
||||
|
||||
return [
|
||||
documentUri.of(options.documentUri),
|
||||
languageId.of('kcl'),
|
||||
workspaceFolders.of(options.workspaceFolders),
|
||||
ViewPlugin.define(
|
||||
(view) =>
|
||||
(plugin = new LanguageServerPlugin(
|
||||
options.client,
|
||||
view,
|
||||
options.allowHTMLContent
|
||||
))
|
||||
),
|
||||
completionDecoration,
|
||||
Prec.highest(completionPlugin(options.client)),
|
||||
Prec.highest(viewCompletionPlugin(options.client)),
|
||||
completionRequester(options.client),
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user