import { CodeEditor } from '@jupyterlab/codeeditor'; import { IDocumentWidget } from '@jupyterlab/docregistry'; import { TranslationBundle } from '@jupyterlab/translation'; import type * as CodeMirror from 'codemirror'; import type * as lsProtocol from 'vscode-languageserver-protocol'; import { StatusMessage, WidgetAdapter } from '../adapters/adapter'; import { LSPConnection } from '../connection'; import { PositionConverter } from '../converter'; import { IEditorIntegrationOptions, IFeature, IFeatureEditorIntegration, IFeatureSettings } from '../feature'; import { IEditorPosition, IRootPosition, IVirtualPosition, offset_at_position } from '../positioning'; import { ILSPLogConsole } from '../tokens'; import { DefaultMap, uris_equal } from '../utils'; import { CodeMirrorHandler, CodeMirrorVirtualEditor, EventName as CodeMirrorEventName } from '../virtual/codemirror_editor'; import { VirtualDocument } from '../virtual/document'; import { IEditorChange } from '../virtual/editor'; function toDocumentChanges(changes: { [uri: string]: lsProtocol.TextEdit[]; }): lsProtocol.TextDocumentEdit[] { let documentChanges = []; for (let uri of Object.keys(changes)) { documentChanges.push({ textDocument: { uri }, edits: changes[uri] } as lsProtocol.TextDocumentEdit); } return documentChanges; } export interface IEditorRange { start: IEditorPosition; end: IEditorPosition; editor: CodeMirror.Editor; } function offset_from_lsp(position: lsProtocol.Position, lines: string[]) { return offset_at_position(PositionConverter.lsp_to_ce(position), lines); } export interface IEditOutcome { appliedChanges: number | null; modifiedCells: number; wasGranular: boolean; errors: string[]; } /** * Interface for storage of HTMLElement event specifications (event name + handler). */ interface IHTMLEventMap< T extends keyof HTMLElementEventMap = keyof HTMLElementEventMap > extends Map void> { set( k: E, handler: (event: HTMLElementEventMap[E]) => void ): this; get(k: E): (event: HTMLElementEventMap[E]) => void; } /** * One feature of each type exists per VirtualDocument * (the initialization is performed by the adapter). */ export abstract class CodeMirrorIntegration implements IFeatureEditorIntegration { is_registered: boolean; feature: IFeature; protected readonly editor_handlers: Map< CodeMirrorEventName, CodeMirrorHandler >; // TODO use better type constraints for connection event names and for responses protected readonly connection_handlers: Map< string, (response: Record) => void >; protected readonly wrapper_handlers: IHTMLEventMap; protected wrapper: HTMLElement; protected virtual_editor: CodeMirrorVirtualEditor; protected virtual_document: VirtualDocument; protected connection: LSPConnection; protected status_message: StatusMessage; protected adapter: WidgetAdapter; protected console: ILSPLogConsole; protected trans: TranslationBundle; get settings(): IFeatureSettings | undefined { return this.feature.settings; } get lab_integration() { return this.feature.labIntegration; } constructor(options: IEditorIntegrationOptions) { this.feature = options.feature; this.virtual_editor = options.virtual_editor as CodeMirrorVirtualEditor; this.virtual_document = options.virtual_document; this.connection = options.connection; this.status_message = options.status_message; this.adapter = options.adapter; this.console = this.adapter.console.scope(options.feature.name); this.trans = options.trans; this.editor_handlers = new Map(); this.connection_handlers = new Map(); this.wrapper_handlers = new Map(); this.is_registered = false; } register(): void { // register editor handlers for (let [event_name, handler] of this.editor_handlers) { this.virtual_editor.on(event_name, handler); } // register connection handlers for (let [event_name, handler] of this.connection_handlers) { this.connection.on(event_name, handler); } // register editor wrapper handlers this.wrapper = this.virtual_editor.getWrapperElement(); for (let [event_name, handler] of this.wrapper_handlers) { this.wrapper.addEventListener(event_name, handler); } this.is_registered = true; } remove(): void { // unregister editor handlers for (let [event_name, handler] of this.editor_handlers) { this.virtual_editor.off(event_name, handler); } this.editor_handlers.clear(); // unregister connection handlers for (let [event_name, handler] of this.connection_handlers) { this.connection.off(event_name, handler); } this.connection_handlers.clear(); // unregister editor wrapper handlers for (let [event_name, handler] of this.wrapper_handlers) { this.wrapper.removeEventListener(event_name, handler); } this.wrapper_handlers.clear(); } protected range_to_editor_range( range: lsProtocol.Range, cm_editor?: CodeMirror.Editor ): IEditorRange { let start = PositionConverter.lsp_to_cm(range.start) as IVirtualPosition; let end = PositionConverter.lsp_to_cm(range.end) as IVirtualPosition; if (cm_editor == null) { let start_in_root = this.transform_virtual_position_to_root_position(start); let ce_editor = this.virtual_editor.get_editor_at_root_position(start_in_root); cm_editor = this.virtual_editor.ce_editor_to_cm_editor.get(ce_editor)!; } return { start: this.virtual_document.transform_virtual_to_editor(start)!, end: this.virtual_document.transform_virtual_to_editor(end)!, editor: cm_editor }; } protected position_from_mouse(event: MouseEvent): IRootPosition | null { const position = this.virtual_editor.coordsChar( { left: event.clientX, top: event.clientY }, 'window' ); if (position.line === -1 && position.ch === -1) { return null; } else { return position as IRootPosition; } } public transform_virtual_position_to_root_position( start: IVirtualPosition ): IRootPosition { let ce_editor = this.virtual_document.virtual_lines.get(start.line)!.editor; let editor_position = this.virtual_document.transform_virtual_to_editor(start); return this.virtual_editor.transform_from_editor_to_root( ce_editor, editor_position! )!; } protected get_cm_editor(position: IRootPosition) { return this.virtual_editor.get_cm_editor(position); } protected get_language_at( position: IEditorPosition, editor: CodeMirror.Editor ) { return editor.getModeAt(position).name; } protected extract_last_character(change: CodeMirror.EditorChange): string { if (change.origin === 'paste') { let last_line = change.text[change.text.length - 1]; return last_line[last_line.length - 1]; } else { return change.text[0][0]; } } protected highlight_range( range: IEditorRange, class_name: string ): CodeMirror.TextMarker { return range.editor .getDoc() .markText(range.start, range.end, { className: class_name }); } /** * Does the edit cover the entire document? */ protected is_whole_document_edit(edit: lsProtocol.TextEdit) { let value = this.virtual_document.value; let lines = value.split('\n'); let range = edit.range; let lsp_to_ce = PositionConverter.lsp_to_ce; return ( offset_at_position(lsp_to_ce(range.start), lines) === 0 && offset_at_position(lsp_to_ce(range.end), lines) === value.length ); } protected async apply_edit( workspaceEdit: lsProtocol.WorkspaceEdit ): Promise { let current_uri = this.virtual_document.document_info.uri; // Specs: documentChanges are preferred over changes let changes = workspaceEdit.documentChanges ? workspaceEdit.documentChanges.map( change => change as lsProtocol.TextDocumentEdit ) : toDocumentChanges(workspaceEdit.changes!); let applied_changes = 0; let edited_cells: number = 0; let is_whole_document_edit: boolean = false; let errors: string[] = []; for (let change of changes) { let uri = change.textDocument.uri; if (!uris_equal(uri, current_uri)) { errors.push( `Workspace-wide edits not implemented: ${uri} != ${current_uri}` ); } else { is_whole_document_edit = change.edits.length === 1 && this.is_whole_document_edit(change.edits[0]); let edit: lsProtocol.TextEdit; if (!is_whole_document_edit) { applied_changes = 0; let value = this.virtual_document.value; // TODO: make sure that it was not changed since the request was sent (using the returned document version) let lines = value.split('\n'); let edits_by_offset = new Map(); for (let e of change.edits) { let offset = offset_from_lsp(e.range.start, lines); if (edits_by_offset.has(offset)) { console.warn( 'Edits should not overlap, ignoring an overlapping edit' ); } else { edits_by_offset.set(offset, e); applied_changes += 1; } } // TODO make use of old_to_new_line for edits which add of remove lines: // this is crucial to preserve cell boundaries in notebook in such cases let old_to_new_line = new DefaultMap(() => []); let new_text = ''; let last_end = 0; let current_old_line = 0; let current_new_line = 0; // going over the edits in descending order of start points: let start_offsets = [...edits_by_offset.keys()].sort((a, b) => a - b); for (let start of start_offsets) { let edit = edits_by_offset.get(start)!; let prefix = value.slice(last_end, start); for (let i = 0; i < prefix.split('\n').length; i++) { let new_lines = old_to_new_line.get_or_create(current_old_line); new_lines.push(current_new_line); current_old_line += 1; current_new_line += 1; } new_text += prefix + edit.newText; let end = offset_from_lsp(edit.range.end, lines); let replaced_fragment = value.slice(start, end); for (let i = 0; i < edit.newText.split('\n').length; i++) { if (i < replaced_fragment.length) { current_old_line += 1; } current_new_line += 1; let new_lines = old_to_new_line.get_or_create(current_old_line); new_lines.push(current_new_line); } last_end = end; } new_text += value.slice(last_end, value.length); edit = { range: { start: { line: 0, character: 0 }, end: { line: lines.length - 1, character: lines[lines.length - 1].length } }, newText: new_text }; console.assert(this.is_whole_document_edit(edit)); } else { edit = change.edits[0]; applied_changes += 1; } edited_cells = this.apply_single_edit(edit); } } const all_empty = changes.every(change => change.edits.length === 0); return { appliedChanges: applied_changes, modifiedCells: edited_cells, wasGranular: !is_whole_document_edit && !all_empty, errors: errors }; } protected replace_fragment( newText: string, editor: CodeEditor.IEditor, fragment_start: CodeMirror.Position, fragment_end: CodeMirror.Position, start: CodeMirror.Position, end: CodeMirror.Position, is_whole_document_edit = false ): number { let document = this.virtual_document; let newFragmentText = newText .split('\n') .slice(fragment_start.line - start.line, fragment_end.line - start.line) .join('\n'); if (newFragmentText.endsWith('\n')) { newFragmentText = newFragmentText.slice(0, -1); } let doc = this.virtual_editor.ce_editor_to_cm_editor.get(editor)!.getDoc(); let raw_value = doc.getValue('\n'); // extract foreign documents and substitute magics, // as it was done when the shadow virtual document was being created let { lines } = document.prepare_code_block({ value: raw_value, ce_editor: editor }); let old_value = lines.join('\n'); if (is_whole_document_edit) { // partial edit let cm_to_ce = PositionConverter.cm_to_ce; let up_to_offset = offset_at_position(cm_to_ce(start), lines); let from_offset = offset_at_position(cm_to_ce(end), lines); newFragmentText = old_value.slice(0, up_to_offset) + newText + old_value.slice(from_offset); } if (old_value === newFragmentText) { return 0; } let new_value = document.decode_code_block(newFragmentText); let cursor = doc.getCursor(); doc.replaceRange( new_value, { line: 0, ch: 0 }, { line: fragment_end.line - fragment_start.line + 1, ch: 0 } ); try { // try to restore the cursor to the position prior to the edit // (this might not be the best UX, but definitely better than // the cursor jumping to the very end of the cell/file). doc.setCursor(cursor, cursor.ch, { scroll: false }); // note: this does not matter for actions invoke from context menu // as those loose focus anyways (this might be addressed elsewhere) } catch (e) { console.log('Could not place the cursor back', e); } return 1; } afterChange(change: IEditorChange, root_position: IRootPosition) { // nothing here } protected apply_single_edit(edit: lsProtocol.TextEdit): number { let document = this.virtual_document; let applied_changes = 0; let start = PositionConverter.lsp_to_cm(edit.range.start); let end = PositionConverter.lsp_to_cm(edit.range.end); let start_editor = document.get_editor_at_virtual_line( start as IVirtualPosition ); let end_editor = document.get_editor_at_virtual_line( end as IVirtualPosition ); if (start_editor !== end_editor) { let last_editor = start_editor; let fragment_start = start; let fragment_end = { ...start }; let line = start.line; let recently_replaced = false; while (line <= end.line) { line++; let editor = document.get_editor_at_virtual_line({ line: line, ch: 0 } as IVirtualPosition); if (editor === last_editor) { fragment_end.line = line; fragment_end.ch = 0; recently_replaced = false; } else { applied_changes += this.replace_fragment( edit.newText, last_editor, fragment_start, fragment_end, start, end ); recently_replaced = true; fragment_start = { line: line, ch: 0 }; fragment_end = { line: line, ch: 0 }; last_editor = editor; } } if (!recently_replaced) { applied_changes += this.replace_fragment( edit.newText, last_editor, fragment_start, fragment_end, start, end ); } } else { applied_changes += this.replace_fragment( edit.newText, start_editor, start, end, start, end ); } return applied_changes; } } export type CodeMirrorIntegrationConstructor = { new (options: IEditorIntegrationOptions): CodeMirrorIntegration; };