mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-11 15:34:37 +08:00
Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com> Co-authored-by: José Valim <jose.valim@dashbit.co>
661 lines
18 KiB
JavaScript
661 lines
18 KiB
JavaScript
import {
|
|
EditorView,
|
|
hoverTooltip,
|
|
keymap,
|
|
highlightSpecialChars,
|
|
drawSelection,
|
|
highlightActiveLine,
|
|
dropCursor,
|
|
rectangularSelection,
|
|
crosshairCursor,
|
|
lineNumbers,
|
|
highlightActiveLineGutter,
|
|
} from "@codemirror/view";
|
|
import { EditorState, EditorSelection } from "@codemirror/state";
|
|
import {
|
|
indentOnInput,
|
|
bracketMatching,
|
|
foldGutter,
|
|
LanguageDescription,
|
|
codeFolding,
|
|
syntaxTree,
|
|
} from "@codemirror/language";
|
|
import { history } from "@codemirror/commands";
|
|
import { highlightSelectionMatches } from "@codemirror/search";
|
|
import {
|
|
autocompletion,
|
|
closeBrackets,
|
|
snippetCompletion,
|
|
} from "@codemirror/autocomplete";
|
|
import { setDiagnostics } from "@codemirror/lint";
|
|
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
|
import { vim } from "@replit/codemirror-vim";
|
|
import { emacs } from "@replit/codemirror-emacs";
|
|
|
|
import { collab, deltaToChanges } from "./live_editor/codemirror/collab";
|
|
import { collabMarkers } from "./live_editor/codemirror/collab_markers";
|
|
import { theme, lightTheme } from "./live_editor/codemirror/theme";
|
|
import {
|
|
clearDoctests,
|
|
updateDoctests,
|
|
} from "./live_editor/codemirror/doctests";
|
|
import { signature } from "./live_editor/codemirror/signature";
|
|
import { formatter } from "./live_editor/codemirror/formatter";
|
|
import { replacedSuffixLength } from "../../lib/text_utils";
|
|
import { settingsStore } from "../../lib/settings";
|
|
import Delta from "../../lib/delta";
|
|
import Markdown from "../../lib/markdown";
|
|
import { readOnlyHint } from "./live_editor/codemirror/read_only_hint";
|
|
import { isMacOS, wait } from "../../lib/utils";
|
|
import Emitter from "../../lib/emitter";
|
|
import CollabClient from "./live_editor/collab_client";
|
|
import { languages } from "./live_editor/codemirror/languages";
|
|
import {
|
|
exitMulticursor,
|
|
insertBlankLineAndCloseHints,
|
|
} from "./live_editor/codemirror/commands";
|
|
import { ancestorNode, closestNode } from "./live_editor/codemirror/tree_utils";
|
|
import { selectingClass } from "./live_editor/codemirror/selecting_class";
|
|
import { globalPubsub } from "../../lib/pubsub";
|
|
|
|
/**
|
|
* Mounts cell source editor with real-time collaboration mechanism.
|
|
*
|
|
* The actual editor must be mounted explicitly by calling either of
|
|
* the `mount` or `focus` methods, but it can be done at any point.
|
|
* Even when not mounted, the editor consumes collaborative updates
|
|
* and invokes change listeners.
|
|
*/
|
|
export default class LiveEditor {
|
|
/** @private */
|
|
_onMount = new Emitter();
|
|
|
|
/**
|
|
* Registers a callback called when the editor is mounted in DOM.
|
|
*/
|
|
onMount = this._onMount.event;
|
|
|
|
/** @private */
|
|
_onChange = new Emitter();
|
|
|
|
/**
|
|
* Registers a callback called with a new cell content whenever it changes.
|
|
*/
|
|
onChange = this._onChange.event;
|
|
|
|
/** @private */
|
|
_onBlur = new Emitter();
|
|
|
|
/**
|
|
* Registers a callback called whenever the editor loses focus.
|
|
*/
|
|
onBlur = this._onBlur.event;
|
|
|
|
/** @private */
|
|
_onFocus = new Emitter();
|
|
|
|
/**
|
|
* Registers a callback called whenever the editor gains focus.
|
|
*/
|
|
onFocus = this._onFocus.event;
|
|
|
|
constructor(
|
|
container,
|
|
connection,
|
|
source,
|
|
revision,
|
|
language,
|
|
intellisense,
|
|
readOnly,
|
|
) {
|
|
this.container = container;
|
|
this.source = source;
|
|
this.language = language;
|
|
this.intellisense = intellisense;
|
|
this.readOnly = readOnly;
|
|
this.initialWidgets = {};
|
|
|
|
this.connection = connection;
|
|
this.collabClient = new CollabClient(connection, revision);
|
|
|
|
this.deltaSubscription = this.collabClient.onDelta((delta, info) => {
|
|
this.source = delta.applyToString(this.source);
|
|
this._onChange.dispatch(this.source);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Checks if an editor instance has been mounted in the DOM.
|
|
*/
|
|
isMounted() {
|
|
return !!this.view;
|
|
}
|
|
|
|
/**
|
|
* Mounts and configures an editor instance in the DOM.
|
|
*/
|
|
mount() {
|
|
if (this.isMounted()) {
|
|
throw new Error("The editor is already mounted");
|
|
}
|
|
|
|
this.mountEditor();
|
|
|
|
this.setInitialWidgets();
|
|
|
|
this._onMount.dispatch();
|
|
}
|
|
|
|
/**
|
|
* Returns current editor content.
|
|
*/
|
|
getSource() {
|
|
return this.source;
|
|
}
|
|
|
|
/**
|
|
* Returns an element closest to the current main cursor position.
|
|
*/
|
|
getElementAtCursor() {
|
|
if (!this.isMounted()) {
|
|
return this.container;
|
|
}
|
|
|
|
const { node } = this.view.domAtPos(this.view.state.selection.main.head);
|
|
if (node instanceof Element) return node;
|
|
return node.parentElement;
|
|
}
|
|
|
|
/**
|
|
* Focuses the editor.
|
|
*
|
|
* Note that this forces the editor to be mounted, if it is not already
|
|
* mounted.
|
|
*/
|
|
focus() {
|
|
if (!this.isMounted()) {
|
|
this.mount();
|
|
}
|
|
|
|
this.view.focus();
|
|
}
|
|
|
|
/**
|
|
* Updates editor selection such that cursor points to the given line.
|
|
*/
|
|
moveCursorToLine(lineNumber) {
|
|
const line = this.view.state.doc.line(lineNumber);
|
|
|
|
this.view.dispatch({
|
|
selection: EditorSelection.single(line.from),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Removes focus from the editor.
|
|
*/
|
|
blur() {
|
|
if (this.isMounted() && this.view.hasFocus) {
|
|
this.view.contentDOM.blur();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Performs necessary cleanup actions.
|
|
*/
|
|
destroy() {
|
|
if (this.isMounted()) {
|
|
this.view.destroy();
|
|
}
|
|
|
|
this.collabClient.destroy();
|
|
this.deltaSubscription.destroy();
|
|
}
|
|
|
|
/**
|
|
* Either adds or updates doctest indicators.
|
|
*/
|
|
updateDoctests(doctestReports) {
|
|
if (this.isMounted()) {
|
|
updateDoctests(this.view, doctestReports);
|
|
} else {
|
|
this.initialWidgets.doctestReportsByLine =
|
|
this.initialWidgets.doctestReportsByLine || {};
|
|
|
|
for (const report of doctestReports) {
|
|
this.initialWidgets.doctestReportsByLine[report.line] = report;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes doctest indicators.
|
|
*/
|
|
clearDoctests() {
|
|
if (this.isMounted()) {
|
|
clearDoctests(this.view);
|
|
} else {
|
|
delete this.initialWidgets.doctestReportsByLine;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets underline markers for warnings and errors.
|
|
*
|
|
* Passing an empty list clears all markers.
|
|
*/
|
|
setCodeMarkers(codeMarkers) {
|
|
if (this.isMounted()) {
|
|
const doc = this.view.state.doc;
|
|
|
|
const diagnostics = codeMarkers.map((marker) => {
|
|
const line = doc.line(marker.line);
|
|
|
|
const [, leadingWhitespace, trailingWhitespace] =
|
|
line.text.match(/^(\s*).*?(\s*)$/);
|
|
|
|
const from = line.from + leadingWhitespace.length;
|
|
const to = line.to - trailingWhitespace.length;
|
|
|
|
return {
|
|
from,
|
|
to,
|
|
severity: marker.severity,
|
|
message: marker.description,
|
|
};
|
|
});
|
|
|
|
this.view.dispatch(setDiagnostics(this.view.state, diagnostics));
|
|
} else {
|
|
this.initialWidgets.codeMarkers = codeMarkers;
|
|
}
|
|
}
|
|
|
|
/** @private */
|
|
mountEditor() {
|
|
const settings = settingsStore.get();
|
|
|
|
const formatLineNumber = (number) => number.toString().padStart(3, " ");
|
|
|
|
const foldGutterMarkerDOM = (open) => {
|
|
const node = document.createElement("i");
|
|
node.classList.add(
|
|
open ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line",
|
|
open ? "cm-gutterFoldMarker-open" : null,
|
|
);
|
|
return node;
|
|
};
|
|
|
|
const fontSizeTheme = EditorView.theme({
|
|
"&": { fontSize: `${settings.editor_font_size}px` },
|
|
});
|
|
|
|
const ligaturesTheme = EditorView.theme({
|
|
"&": {
|
|
fontVariantLigatures: `${settings.editor_ligatures ? "normal" : "none"}`,
|
|
},
|
|
});
|
|
|
|
const lineWrappingEnabled =
|
|
this.language === "markdown" && settings.editor_markdown_word_wrap;
|
|
|
|
const language =
|
|
this.language &&
|
|
LanguageDescription.matchLanguageName(languages, this.language, false);
|
|
|
|
const customKeymap = [
|
|
{ key: "Escape", run: exitMulticursor },
|
|
{ key: "Alt-Enter", run: insertBlankLineAndCloseHints },
|
|
];
|
|
|
|
this.view = new EditorView({
|
|
parent: this.container,
|
|
doc: this.source,
|
|
extensions: [
|
|
lineNumbers({ formatNumber: formatLineNumber }),
|
|
highlightActiveLine(),
|
|
highlightActiveLineGutter(),
|
|
highlightSpecialChars(),
|
|
highlightSelectionMatches(),
|
|
foldGutter({ markerDOM: foldGutterMarkerDOM }),
|
|
codeFolding({ placeholderText: "⋯" }),
|
|
drawSelection(),
|
|
dropCursor(),
|
|
rectangularSelection(),
|
|
selectingClass(),
|
|
crosshairCursor(),
|
|
EditorState.allowMultipleSelections.of(true),
|
|
bracketMatching(),
|
|
closeBrackets(),
|
|
indentOnInput(),
|
|
history(),
|
|
EditorState.readOnly.of(this.readOnly),
|
|
readOnlyHint(),
|
|
keymap.of(customKeymap),
|
|
keymap.of(vscodeKeymap),
|
|
EditorState.tabSize.of(2),
|
|
EditorState.lineSeparator.of("\n"),
|
|
lineWrappingEnabled ? EditorView.lineWrapping : [],
|
|
// We bind tab to actions within the editor, which would trap
|
|
// the user if they tabbed into the editor, so we remove it
|
|
// from the tab navigation
|
|
EditorView.contentAttributes.of({ tabIndex: -1 }),
|
|
fontSizeTheme,
|
|
settings.editor_theme === "light" ? lightTheme : theme,
|
|
ligaturesTheme,
|
|
collab(this.collabClient),
|
|
collabMarkers(this.collabClient),
|
|
autocompletion({
|
|
activateOnTyping: settings.editor_auto_completion,
|
|
defaultKeymap: false,
|
|
}),
|
|
this.intellisense
|
|
? [
|
|
autocompletion({ override: [this.completionSource.bind(this)] }),
|
|
hoverTooltip(this.docsHoverTooltipSource.bind(this)),
|
|
signature(this.signatureSource.bind(this), {
|
|
activateOnTyping: settings.editor_auto_signature,
|
|
}),
|
|
formatter(this.formatterSource.bind(this)),
|
|
]
|
|
: [],
|
|
settings.editor_mode === "vim" ? [vim()] : [],
|
|
settings.editor_mode === "emacs" ? [emacs()] : [],
|
|
language ? language.support : [],
|
|
EditorView.domEventHandlers({
|
|
click: this.handleEditorClick.bind(this),
|
|
keydown: this.handleEditorKeydown.bind(this),
|
|
blur: this.handleEditorBlur.bind(this),
|
|
focus: this.handleEditorFocus.bind(this),
|
|
}),
|
|
EditorView.clickAddsSelectionRange.of((event) => event.altKey),
|
|
],
|
|
});
|
|
}
|
|
|
|
/** @private */
|
|
handleEditorClick(event) {
|
|
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;
|
|
|
|
if (cmd) {
|
|
this.jumpToDefinition(this.view);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @private */
|
|
handleEditorKeydown(event) {
|
|
// We dispatch escape event, but only if it is not consumed by any
|
|
// registered handler in the editor, such as closing autocompletion
|
|
// or escaping Vim insert mode
|
|
|
|
if (event.key === "Escape") {
|
|
this.container.dispatchEvent(
|
|
new CustomEvent("lb:editor_escape", { bubbles: true }),
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @private */
|
|
handleEditorBlur(event) {
|
|
if (!this.container.contains(event.relatedTarget)) {
|
|
this._onBlur.dispatch();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @private */
|
|
handleEditorFocus(event) {
|
|
this._onFocus.dispatch();
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @private */
|
|
completionSource(context) {
|
|
const settings = settingsStore.get();
|
|
|
|
// Trigger completion implicitly only for identifiers and members
|
|
const triggerBeforeCursor = context.matchBefore(/[\w?!.]$/);
|
|
|
|
if (!triggerBeforeCursor && !context.explicit) {
|
|
return null;
|
|
}
|
|
|
|
const textUntilCursor = this.getCompletionHint(context);
|
|
|
|
return this.connection
|
|
.intellisenseRequest("completion", {
|
|
hint: textUntilCursor,
|
|
editor_auto_completion: settings.editor_auto_completion,
|
|
})
|
|
.then((response) => {
|
|
if (response.items.length === 0) return null;
|
|
|
|
const completions = response.items.map((item, index) => {
|
|
const completion = this.completionItemToCompletions(item);
|
|
|
|
return {
|
|
...completion,
|
|
// Keep the ordering from the server
|
|
boost: 1 - index / response.items.length,
|
|
};
|
|
});
|
|
|
|
const replaceLength = replacedSuffixLength(
|
|
textUntilCursor,
|
|
response.items[0].insert_text,
|
|
);
|
|
|
|
return {
|
|
from: context.pos - replaceLength,
|
|
options: completions,
|
|
validFor: /^\w*[!?]?$/,
|
|
};
|
|
})
|
|
.catch(() => null);
|
|
}
|
|
|
|
/** @private */
|
|
getCompletionHint(context) {
|
|
// By default we only send the current line content until cursor
|
|
// as completion hint. We use the local AST to send more context
|
|
// for multiline expressions where we know it's relevant.
|
|
|
|
const tree = syntaxTree(context.state);
|
|
const node = tree.resolve(context.pos);
|
|
|
|
if (node && this.language === "elixir") {
|
|
const boundaryNode = closestNode(node, ["Map", "Bitstring"]);
|
|
|
|
if (boundaryNode) {
|
|
return context.state.doc.sliceString(boundaryNode.from, context.pos);
|
|
}
|
|
}
|
|
|
|
return context.matchBefore(/^.*/).text;
|
|
}
|
|
|
|
/** @private */
|
|
completionItemToCompletions(item) {
|
|
const completion = {
|
|
label: item.label,
|
|
type: item.kind,
|
|
info: (completion) => {
|
|
if (item.documentation === null) return null;
|
|
|
|
// The info popup is shown automatically, we delay it a bit
|
|
// to not distract the user too much as they are typing
|
|
return wait(350).then(() => {
|
|
const node = document.createElement("div");
|
|
node.classList.add("cm-completionInfoDocs");
|
|
node.classList.add("cm-markdown");
|
|
new Markdown(node, item.documentation, {
|
|
defaultCodeLanguage: this.language,
|
|
useDarkTheme: this.usesDarkTheme(),
|
|
});
|
|
return node;
|
|
});
|
|
},
|
|
};
|
|
|
|
// Place cursor at the end, if not explicitly specified
|
|
const template = item.insert_text.includes("${}")
|
|
? item.insert_text
|
|
: item.insert_text + "${}";
|
|
|
|
return snippetCompletion(template, completion);
|
|
}
|
|
|
|
/** @private */
|
|
docsHoverTooltipSource(view, pos, side) {
|
|
const line = view.state.doc.lineAt(pos);
|
|
const lineLength = line.to - line.from;
|
|
|
|
const text = line.text;
|
|
// If we are on the right side of the position, we add one to
|
|
// convert it to column
|
|
const column = pos - line.from + (side === 1 ? 1 : 0);
|
|
if (column < 1 || column > lineLength) return null;
|
|
|
|
return this.connection
|
|
.intellisenseRequest("details", { line: text, column })
|
|
.then((response) => {
|
|
// Note: the response range is a right-exclusive column range
|
|
|
|
return {
|
|
pos: line.from + response.range.from - 1,
|
|
end: line.from + response.range.to - 1,
|
|
above: true,
|
|
create: (view) => {
|
|
const dom = document.createElement("div");
|
|
dom.classList.add("cm-hoverDocs");
|
|
|
|
for (const content of response.contents) {
|
|
const item = document.createElement("div");
|
|
item.classList.add("cm-hoverDocsContent");
|
|
item.classList.add("cm-markdown");
|
|
dom.appendChild(item);
|
|
|
|
new Markdown(item, content, {
|
|
defaultCodeLanguage: this.language,
|
|
useDarkTheme: this.usesDarkTheme(),
|
|
});
|
|
}
|
|
|
|
return { dom };
|
|
},
|
|
};
|
|
})
|
|
.catch(() => null);
|
|
}
|
|
|
|
/** @private */
|
|
jumpToDefinition(view) {
|
|
const pos = view.state.selection.main.head;
|
|
const line = view.state.doc.lineAt(pos);
|
|
const lineLength = line.to - line.from;
|
|
const text = line.text;
|
|
|
|
const column = pos - line.from;
|
|
if (column < 1 || column > lineLength) return null;
|
|
|
|
return this.connection
|
|
.intellisenseRequest("definition", { line: text, column })
|
|
.then((response) => {
|
|
globalPubsub.broadcast("jump_to_editor", {
|
|
line: response.line,
|
|
file: response.file,
|
|
});
|
|
|
|
return true;
|
|
})
|
|
.catch(() => false);
|
|
}
|
|
|
|
/** @private */
|
|
signatureSource({ state, pos }) {
|
|
const textUntilCursor = this.getSignatureHint(state, pos);
|
|
|
|
return this.connection
|
|
.intellisenseRequest("signature", {
|
|
hint: textUntilCursor,
|
|
})
|
|
.then((response) => {
|
|
return {
|
|
activeArgumentIdx: response.active_argument,
|
|
items: response.items,
|
|
};
|
|
})
|
|
.catch(() => null);
|
|
}
|
|
|
|
/** @private */
|
|
getSignatureHint(state, pos) {
|
|
// By default we send all text until cursor as signature hint.
|
|
// We use the local AST to limit the hint to the relevanat call
|
|
// expression.
|
|
|
|
const tree = syntaxTree(state);
|
|
const node = tree.resolve(pos);
|
|
|
|
if (node && this.language === "elixir") {
|
|
let callNode = closestNode(node, [
|
|
"Call",
|
|
"FunctionDefinitionCall",
|
|
"KernelCall",
|
|
]);
|
|
|
|
if (callNode) {
|
|
const pipeNode = ancestorNode(callNode, ["Right", "PipeOperator"]);
|
|
const boundaryNode = pipeNode || callNode;
|
|
|
|
return state.doc.sliceString(boundaryNode.from, pos);
|
|
}
|
|
}
|
|
|
|
return state.doc.sliceString(0, pos);
|
|
}
|
|
|
|
formatterSource(doc) {
|
|
return this.connection
|
|
.intellisenseRequest("format", { code: doc.toString() })
|
|
.then((response) => {
|
|
this.setCodeMarkers(response.code_markers);
|
|
|
|
if (response.delta) {
|
|
const delta = Delta.fromCompressed(response.delta);
|
|
return deltaToChanges(delta);
|
|
} else {
|
|
return null;
|
|
}
|
|
})
|
|
.catch(() => null);
|
|
}
|
|
|
|
/** @private */
|
|
setInitialWidgets() {
|
|
if (this.initialWidgets.doctestReportsByLine) {
|
|
const doctestReports = Object.values(
|
|
this.initialWidgets.doctestReportsByLine,
|
|
);
|
|
this.updateDoctests(doctestReports);
|
|
}
|
|
|
|
if (this.initialWidgets.codeMarkers) {
|
|
this.setCodeMarkers(this.initialWidgets.codeMarkers);
|
|
}
|
|
|
|
this.initialWidgets = {};
|
|
}
|
|
|
|
/** @private */
|
|
usesDarkTheme() {
|
|
const settings = settingsStore.get();
|
|
return settings.editor_theme !== "light";
|
|
}
|
|
}
|