* Convert commandBarMachine to standalone actor * Switch all uses of CommandBarProvider pattern to use actor and selector snapshots directly
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
import { EditorView, ViewUpdate } from '@codemirror/view'
|
|
import { syntaxTree } from '@codemirror/language'
|
|
import { EditorSelection, Annotation, Transaction } from '@codemirror/state'
|
|
import { engineCommandManager, kclManager } from 'lib/singletons'
|
|
import { modelingMachine, ModelingMachineEvent } from 'machines/modelingMachine'
|
|
import { Selections, Selection, processCodeMirrorRanges } from 'lib/selections'
|
|
import { undo, redo } from '@codemirror/commands'
|
|
import { addLineHighlight, addLineHighlightEvent } from './highlightextension'
|
|
import {
|
|
Diagnostic,
|
|
forEachDiagnostic,
|
|
setDiagnosticsEffect,
|
|
} from '@codemirror/lint'
|
|
import { StateFrom } from 'xstate'
|
|
import { markOnce } from 'lib/performance'
|
|
import { kclEditorActor } from 'machines/kclEditorMachine'
|
|
|
|
declare global {
|
|
interface Window {
|
|
EditorSelection: typeof EditorSelection
|
|
EditorView: typeof EditorView
|
|
}
|
|
}
|
|
|
|
// We need to be able to create these during tests dynamically (via
|
|
// page.evaluate) So that's why this exists.
|
|
window.EditorSelection = EditorSelection
|
|
window.EditorView = EditorView
|
|
|
|
const updateOutsideEditorAnnotation = Annotation.define<boolean>()
|
|
export const updateOutsideEditorEvent = updateOutsideEditorAnnotation.of(true)
|
|
|
|
const modelingMachineAnnotation = Annotation.define<boolean>()
|
|
export const modelingMachineEvent = modelingMachineAnnotation.of(true)
|
|
|
|
const setDiagnosticsAnnotation = Annotation.define<boolean>()
|
|
export const setDiagnosticsEvent = setDiagnosticsAnnotation.of(true)
|
|
|
|
export default class EditorManager {
|
|
private _copilotEnabled: boolean = true
|
|
|
|
private _isAllTextSelected: boolean = false
|
|
private _isShiftDown: boolean = false
|
|
private _selectionRanges: Selections = {
|
|
otherSelections: [],
|
|
graphSelections: [],
|
|
}
|
|
|
|
private _lastEvent: { event: string; time: number } | null = null
|
|
|
|
private _modelingSend: (eventInfo: ModelingMachineEvent) => void = () => {}
|
|
private _modelingState: StateFrom<typeof modelingMachine> | null = null
|
|
|
|
private _convertToVariableEnabled: boolean = false
|
|
private _convertToVariableCallback: () => void = () => {}
|
|
|
|
private _highlightRange: Array<[number, number]> = [[0, 0]]
|
|
|
|
public _editorView: EditorView | null = null
|
|
|
|
setCopilotEnabled(enabled: boolean) {
|
|
this._copilotEnabled = enabled
|
|
}
|
|
|
|
get copilotEnabled(): boolean {
|
|
return this._copilotEnabled
|
|
}
|
|
|
|
setEditorView(editorView: EditorView) {
|
|
this._editorView = editorView
|
|
kclEditorActor.send({ type: 'setKclEditorMounted', data: true })
|
|
this.overrideTreeHighlighterUpdateForPerformanceTracking()
|
|
}
|
|
|
|
overrideTreeHighlighterUpdateForPerformanceTracking() {
|
|
// @ts-ignore
|
|
this._editorView?.plugins.forEach((e) => {
|
|
let sawATreeDiff = false
|
|
|
|
// we cannot use <>.constructor.name since it will get destroyed
|
|
// when packaging the application.
|
|
const isTreeHighlightPlugin =
|
|
e?.value &&
|
|
e.value?.hasOwnProperty('tree') &&
|
|
e.value?.hasOwnProperty('decoratedTo') &&
|
|
e.value?.hasOwnProperty('decorations')
|
|
|
|
if (isTreeHighlightPlugin) {
|
|
let originalUpdate = e.value.update
|
|
// @ts-ignore
|
|
function performanceTrackingUpdate(args) {
|
|
/**
|
|
* TreeHighlighter.update will be called multiple times on start up.
|
|
* We do not want to track the highlight performance of an empty update.
|
|
* mark the syntax highlight one time when the new tree comes in with the
|
|
* initial code
|
|
*/
|
|
const treeIsDifferent =
|
|
// @ts-ignore
|
|
!sawATreeDiff && this.tree !== syntaxTree(args.state)
|
|
if (treeIsDifferent && !sawATreeDiff) {
|
|
markOnce('code/willSyntaxHighlight')
|
|
}
|
|
// Call the original function
|
|
// @ts-ignore
|
|
originalUpdate.apply(this, [args])
|
|
if (treeIsDifferent && !sawATreeDiff) {
|
|
markOnce('code/didSyntaxHighlight')
|
|
sawATreeDiff = true
|
|
}
|
|
}
|
|
e.value.update = performanceTrackingUpdate
|
|
}
|
|
})
|
|
}
|
|
|
|
get isAllTextSelected() {
|
|
return this._isAllTextSelected
|
|
}
|
|
|
|
get editorView(): EditorView | null {
|
|
return this._editorView
|
|
}
|
|
|
|
get isShiftDown(): boolean {
|
|
return this._isShiftDown
|
|
}
|
|
|
|
setIsShiftDown(isShiftDown: boolean) {
|
|
this._isShiftDown = isShiftDown
|
|
}
|
|
|
|
private selectionsWithSafeEnds(
|
|
selection: Array<Selection['codeRef']['range']>
|
|
): Array<[number, number]> {
|
|
if (!this._editorView) {
|
|
return selection.map((s): [number, number] => {
|
|
return [s[0], s[1]]
|
|
})
|
|
}
|
|
|
|
return selection.map((s): [number, number] => {
|
|
const safeEnd = Math.min(s[1], this._editorView?.state.doc.length || s[1])
|
|
return [s[0], safeEnd]
|
|
})
|
|
}
|
|
|
|
set selectionRanges(selectionRanges: Selections) {
|
|
this._selectionRanges = selectionRanges
|
|
}
|
|
|
|
set modelingSend(send: (eventInfo: ModelingMachineEvent) => void) {
|
|
this._modelingSend = send
|
|
}
|
|
|
|
set modelingState(state: StateFrom<typeof modelingMachine>) {
|
|
this._modelingState = state
|
|
}
|
|
|
|
get highlightRange(): Array<[number, number]> {
|
|
return this._highlightRange
|
|
}
|
|
|
|
setHighlightRange(range: Array<Selection['codeRef']['range']>): void {
|
|
const selectionsWithSafeEnds = this.selectionsWithSafeEnds(range)
|
|
|
|
this._highlightRange = selectionsWithSafeEnds
|
|
|
|
if (this._editorView) {
|
|
this._editorView.dispatch({
|
|
effects: addLineHighlight.of(selectionsWithSafeEnds),
|
|
annotations: [
|
|
updateOutsideEditorEvent,
|
|
addLineHighlightEvent,
|
|
Transaction.addToHistory.of(false),
|
|
],
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given an array of Diagnostics remove any duplicates by hashing a key
|
|
* in the format of from + ' ' + to + ' ' + message.
|
|
*/
|
|
makeUniqueDiagnostics(duplicatedDiagnostics: Diagnostic[]): Diagnostic[] {
|
|
const uniqueDiagnostics: Diagnostic[] = []
|
|
const seenDiagnostic: { [key: string]: boolean } = {}
|
|
|
|
duplicatedDiagnostics.forEach((diagnostic: Diagnostic) => {
|
|
const hash = `${diagnostic.from} ${diagnostic.to} ${diagnostic.message}`
|
|
if (!seenDiagnostic[hash]) {
|
|
uniqueDiagnostics.push(diagnostic)
|
|
seenDiagnostic[hash] = true
|
|
}
|
|
})
|
|
|
|
return uniqueDiagnostics
|
|
}
|
|
|
|
setDiagnostics(diagnostics: Diagnostic[]): void {
|
|
if (!this._editorView) return
|
|
// Clear out any existing diagnostics that are the same.
|
|
diagnostics = this.makeUniqueDiagnostics(diagnostics)
|
|
|
|
this._editorView.dispatch({
|
|
effects: [setDiagnosticsEffect.of(diagnostics)],
|
|
annotations: [
|
|
setDiagnosticsEvent,
|
|
updateOutsideEditorEvent,
|
|
Transaction.addToHistory.of(false),
|
|
],
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Scroll to the first selection in the editor.
|
|
*/
|
|
scrollToSelection() {
|
|
if (!this._editorView || !this._selectionRanges.graphSelections[0]) return
|
|
|
|
const firstSelection = this._selectionRanges.graphSelections[0]
|
|
|
|
this._editorView.focus()
|
|
this._editorView.dispatch({
|
|
effects: [
|
|
EditorView.scrollIntoView(
|
|
EditorSelection.range(
|
|
firstSelection.codeRef.range[0],
|
|
firstSelection.codeRef.range[1]
|
|
),
|
|
{ y: 'center' }
|
|
),
|
|
],
|
|
annotations: [
|
|
updateOutsideEditorEvent,
|
|
Transaction.addToHistory.of(false),
|
|
],
|
|
})
|
|
}
|
|
|
|
scrollToFirstErrorDiagnosticIfExists() {
|
|
if (!this._editorView) return
|
|
|
|
let firstDiagnosticPos: [number, number] | null = null
|
|
forEachDiagnostic(
|
|
this._editorView.state,
|
|
(d: Diagnostic, from: number, to: number) => {
|
|
if (!firstDiagnosticPos && d.severity === 'error') {
|
|
firstDiagnosticPos = [from, to]
|
|
}
|
|
}
|
|
)
|
|
|
|
if (!firstDiagnosticPos) return
|
|
|
|
this._editorView.focus()
|
|
this._editorView.dispatch({
|
|
selection: EditorSelection.create([
|
|
EditorSelection.cursor(firstDiagnosticPos[0]),
|
|
]),
|
|
effects: [
|
|
EditorView.scrollIntoView(
|
|
EditorSelection.range(firstDiagnosticPos[0], firstDiagnosticPos[1])
|
|
),
|
|
],
|
|
annotations: [
|
|
updateOutsideEditorEvent,
|
|
Transaction.addToHistory.of(false),
|
|
],
|
|
})
|
|
}
|
|
|
|
undo() {
|
|
if (this._editorView) {
|
|
undo(this._editorView)
|
|
}
|
|
}
|
|
|
|
redo() {
|
|
if (this._editorView) {
|
|
redo(this._editorView)
|
|
}
|
|
}
|
|
|
|
set convertToVariableEnabled(enabled: boolean) {
|
|
this._convertToVariableEnabled = enabled
|
|
}
|
|
|
|
set convertToVariableCallback(callback: () => void) {
|
|
this._convertToVariableCallback = callback
|
|
}
|
|
|
|
convertToVariable() {
|
|
if (this._convertToVariableEnabled) {
|
|
this._convertToVariableCallback()
|
|
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
selectRange(selections: Selections) {
|
|
if (selections?.graphSelections?.length === 0) {
|
|
return
|
|
}
|
|
|
|
if (!this._editorView) {
|
|
return
|
|
}
|
|
const codeBaseSelections = this.createEditorSelection(selections)
|
|
this._editorView.dispatch({
|
|
selection: codeBaseSelections,
|
|
annotations: [
|
|
updateOutsideEditorEvent,
|
|
Transaction.addToHistory.of(false),
|
|
],
|
|
})
|
|
}
|
|
|
|
createEditorSelection(selections: Selections) {
|
|
let codeBasedSelections = []
|
|
for (const selection of selections.graphSelections) {
|
|
const safeEnd = Math.min(
|
|
selection.codeRef.range[1],
|
|
this._editorView?.state.doc.length || selection.codeRef.range[1]
|
|
)
|
|
codeBasedSelections.push(
|
|
EditorSelection.range(selection.codeRef.range[0], safeEnd)
|
|
)
|
|
}
|
|
|
|
const end =
|
|
selections.graphSelections[selections.graphSelections.length - 1].codeRef
|
|
.range[1]
|
|
const safeEnd = Math.min(end, this._editorView?.state.doc.length || end)
|
|
codeBasedSelections.push(EditorSelection.cursor(safeEnd))
|
|
return EditorSelection.create(codeBasedSelections, 1)
|
|
}
|
|
|
|
// We will ONLY get here if the user called a select event.
|
|
// This is handled by the code mirror kcl plugin.
|
|
// If you call this function from somewhere else, you best know wtf you are
|
|
// doing. (jess)
|
|
handleOnViewUpdate(viewUpdate: ViewUpdate): void {
|
|
if (!this._editorView) {
|
|
this.setEditorView(viewUpdate.view)
|
|
}
|
|
|
|
const ranges = viewUpdate?.state?.selection?.ranges || []
|
|
if (ranges.length === 0) {
|
|
return
|
|
}
|
|
|
|
if (!this._modelingState) {
|
|
return
|
|
}
|
|
|
|
if (this._modelingState.matches({ Sketch: 'Change Tool' })) {
|
|
return
|
|
}
|
|
|
|
this._isAllTextSelected = viewUpdate.state.selection.ranges.some(
|
|
(selection) => {
|
|
return (
|
|
// The user will need to select the empty new lines as well to be considered all of the text.
|
|
// CTRL+A is the best way to select all the text
|
|
selection.from === 0 && selection.to === viewUpdate.state.doc.length
|
|
)
|
|
}
|
|
)
|
|
|
|
const eventInfo = processCodeMirrorRanges({
|
|
codeMirrorRanges: viewUpdate.state.selection.ranges,
|
|
selectionRanges: this._selectionRanges,
|
|
isShiftDown: this._isShiftDown,
|
|
ast: kclManager.ast,
|
|
})
|
|
|
|
if (!eventInfo) {
|
|
return
|
|
}
|
|
|
|
const deterministicEventInfo = {
|
|
...eventInfo,
|
|
engineEvents: eventInfo.engineEvents.map((e) => ({
|
|
...e,
|
|
cmd_id: 'static',
|
|
})),
|
|
}
|
|
const stringEvent = JSON.stringify(deterministicEventInfo)
|
|
if (
|
|
this._lastEvent &&
|
|
stringEvent === this._lastEvent.event &&
|
|
Date.now() - this._lastEvent.time < 500
|
|
) {
|
|
return // don't repeat events
|
|
}
|
|
|
|
this._lastEvent = { event: stringEvent, time: Date.now() }
|
|
this._modelingSend(eventInfo.modelingEvent)
|
|
eventInfo.engineEvents.forEach((event) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
engineCommandManager.sendSceneCommand(event)
|
|
})
|
|
}
|
|
}
|