fix(mongodb): use line comments in editor

This commit is contained in:
Võ Văn Thành (Jay)
2026-08-18 23:17:24 +08:00
committed by GitHub
parent 6cbb10439e
commit fa4238c5d3
8 changed files with 370 additions and 4 deletions
@@ -102,7 +102,7 @@ import {
type QueryEditorTableReferencePayload,
} from "@/lib/editor/queryEditorTableDrop";
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap";
@@ -121,6 +121,8 @@ import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorS
import { startsQueryEditorRectangularSelection, usesQueryEditorObjectNavigationModifier } from "@/lib/editor/queryEditorPointerSelection";
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCaretResync";
import { queryEditorCommentTokens, queryEditorLineCommentToken } from "@/lib/editor/queryEditorLineComment";
import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight";
import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection";
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
@@ -4471,7 +4473,7 @@ onMounted(async () => {
langSql,
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion },
{ copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, toggleBlockComment, history, defaultKeymap, historyKeymap },
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree },
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree, highlightingFor, syntaxTree },
{ searchKeymap },
] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("@codemirror/autocomplete"), import("@codemirror/commands"), import("@codemirror/language"), import("@codemirror/search")]);
editorViewModule = {
@@ -4741,10 +4743,17 @@ onMounted(async () => {
override: [async (context: CompletionContext) => provideSqlCompletions(context)],
});
buildSqlLanguageExtension = () =>
const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree });
buildSqlLanguageExtension = () => [
langSql.sql({
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value),
});
}),
// Non-SQL editors (MongoDB shell) keep the SQL grammar for highlighting, so override the
// comment marker that toggleLineComment reads from language data.
Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }])),
// The SQL grammar does not tokenize `//`, so those comments are highlighted by hand.
queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : [],
];
buildSqlSemanticHighlightExtension = () => [
ViewPlugin.fromClass(
class {
@@ -4789,6 +4798,7 @@ onMounted(async () => {
{ decorations: (value) => value.decorations },
),
sqlSemanticHighlightTheme(EditorView),
shellLineCommentTheme(EditorView),
];
const initialSettings = settingsStore.editorSettings;
@@ -0,0 +1,60 @@
// @vitest-environment happy-dom
import { HighlightStyle, highlightingFor, syntaxHighlighting, syntaxTree } from "@codemirror/language";
import { sql } from "@codemirror/lang-sql";
import { EditorState } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin } from "@codemirror/view";
import { tags } from "@lezer/highlight";
import { afterEach, describe, expect, it } from "vitest";
import { SHELL_LINE_COMMENT_CLASS, createShellLineCommentHighlight, shellLineCommentClass } from "@/lib/editor/codemirrorShellLineCommentHighlight";
const commentHighlightStyle = HighlightStyle.define([{ tag: tags.lineComment, color: "#6c7086", fontStyle: "italic" }]);
let view: EditorView | null = null;
afterEach(() => {
view?.destroy();
view = null;
});
function mount(doc: string, extensions: import("@codemirror/state").Extension[] = []) {
view = new EditorView({
state: EditorState.create({
doc,
extensions: [sql(), ...extensions, createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree })],
}),
parent: document.body,
});
return view;
}
describe("createShellLineCommentHighlight", () => {
it("decorates // comments that the SQL grammar leaves untokenized", () => {
const editor = mount('// list users\ndb.sites.insertOne({ url: "https://example.com" })');
const decorated = Array.from(editor.dom.querySelectorAll(`.${SHELL_LINE_COMMENT_CLASS}`)).map((node) => node.textContent);
expect(decorated).toEqual(["// list users"]);
});
it("does not decorate // inside parsed block comments or strings", () => {
const editor = mount('/* hidden // marker */\nSELECT "https://example.com";\n// shown');
const decorated = Array.from(editor.dom.querySelectorAll(`.${SHELL_LINE_COMMENT_CLASS}`)).map((node) => node.textContent);
expect(decorated).toEqual(["// shown"]);
});
it("carries the active theme's comment class so // matches SQL --", () => {
const editor = mount("// list users", [syntaxHighlighting(commentHighlightStyle)]);
const themeClass = highlightingFor(editor.state, [tags.lineComment]);
const node = editor.dom.querySelector(`.${SHELL_LINE_COMMENT_CLASS}`);
expect(themeClass).toBeTruthy();
expect(node?.className.split(" ")).toContain(themeClass?.split(" ")[0]);
});
it("falls back to the bare class when the theme styles no comments", () => {
const state = EditorState.create({ doc: "// list users", extensions: [sql()] });
expect(shellLineCommentClass(state, highlightingFor)).toBe(SHELL_LINE_COMMENT_CLASS);
});
});
@@ -0,0 +1,87 @@
import { readFileSync } from "node:fs";
import { toggleLineComment } from "@codemirror/commands";
import { sql } from "@codemirror/lang-sql";
import { EditorState, Prec, type Transaction } from "@codemirror/state";
import { describe, expect, it, vi } from "vitest";
import { queryEditorCommentTokens, queryEditorLineCommentToken } from "@/lib/editor/queryEditorLineComment";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const editorThemesSource = readFileSync(new URL("../../editor/editorThemes.ts", import.meta.url), "utf8");
const shellHighlightSource = readFileSync(new URL("../../editor/codemirrorShellLineCommentHighlight.ts", import.meta.url), "utf8");
function runToggleLineComment(doc: string, commentToken: string) {
let state = EditorState.create({
doc,
selection: { anchor: 0 },
extensions: [sql(), Prec.highest(EditorState.languageData.of(() => [{ commentTokens: { line: commentToken } }]))],
});
const dispatch = vi.fn((transaction: Transaction) => {
state = transaction.state;
});
const handled = toggleLineComment({
get state() {
return state;
},
dispatch,
} as never);
return { handled, state };
}
describe("queryEditorLineCommentToken", () => {
it("uses the MongoDB shell line comment marker", () => {
expect(queryEditorLineCommentToken("mongodb")).toBe("//");
});
it("keeps block comment tokens while overriding MongoDB line comments", () => {
expect(queryEditorCommentTokens("mongodb")).toEqual({
line: "//",
block: { open: "/*", close: "*/" },
});
});
it("keeps the SQL line comment marker elsewhere", () => {
expect(queryEditorLineCommentToken(undefined)).toBe("--");
expect(queryEditorLineCommentToken("mysql")).toBe("--");
expect(queryEditorLineCommentToken("postgres")).toBe("--");
});
});
describe("QueryEditor line comment", () => {
it("overrides the language comment tokens in the SQL language compartment", () => {
expect(queryEditorSource).toContain("Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }]))");
});
it("highlights // comments with the theme's comment style", () => {
expect(queryEditorSource).toContain('queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : []');
expect(queryEditorSource).toContain("const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree });");
expect(queryEditorSource).toContain("shellLineCommentTheme(EditorView),");
expect(editorThemesSource).toContain('".cm-shell-line-comment *"');
expect(editorThemesSource).toContain('color: "inherit !important"');
});
it("bounds shell comment scanning to syntax-aware visible lines", () => {
expect(shellHighlightSource).toContain("view.state.doc.lineAt(visibleRange.from).from");
expect(shellHighlightSource).toContain("tree.resolveInner(absoluteFrom, 1)");
expect(shellHighlightSource).not.toContain("sliceString(0, end)");
});
it("comments a MongoDB line with //", () => {
const result = runToggleLineComment("db.users.find({})", "//");
expect(result.handled).toBe(true);
expect(result.state.doc.toString()).toBe("// db.users.find({})");
});
it("uncomments a MongoDB line commented with //", () => {
const result = runToggleLineComment("// db.users.find({})", "//");
expect(result.state.doc.toString()).toBe("db.users.find({})");
});
it("still comments SQL with --", () => {
const result = runToggleLineComment("SELECT 1", "--");
expect(result.state.doc.toString()).toBe("-- SELECT 1");
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { shellLineCommentRanges } from "@/lib/editor/shellLineCommentRanges";
function commentTexts(doc: string): string[] {
return shellLineCommentRanges(doc).map((range) => doc.slice(range.from, range.to));
}
describe("shellLineCommentRanges", () => {
it("finds a whole-line comment", () => {
expect(commentTexts("// list users\ndb.users.find({})")).toEqual(["// list users"]);
});
it("finds a trailing comment and stops at the line end", () => {
expect(commentTexts("db.users.find({}) // only active\ndb.users.count()")).toEqual(["// only active"]);
});
it("ignores // inside quoted strings", () => {
expect(commentTexts('db.sites.insertOne({ url: "https://example.com" })')).toEqual([]);
expect(commentTexts("db.sites.insertOne({ url: 'https://example.com' })")).toEqual([]);
expect(commentTexts("db.sites.insertOne({ url: `https://example.com` })")).toEqual([]);
});
it("ignores // inside block comments", () => {
expect(commentTexts("/* db.users.find({}) // stale */\ndb.users.count()")).toEqual([]);
});
it("handles escaped quotes without swallowing the rest of the document", () => {
expect(commentTexts('db.t.find({ a: "x\\"y" }) // note')).toEqual(["// note"]);
});
it("does not let an unterminated quote hide later comments", () => {
expect(commentTexts('db.t.find({ a: "oops\n// note')).toEqual(["// note"]);
});
it("keeps carriage returns out of the range", () => {
expect(commentTexts("// note\r\ndb.users.count()")).toEqual(["// note"]);
});
it("respects the scan limit", () => {
const doc = "db.a.find()\n// second line";
expect(shellLineCommentRanges(doc, doc.indexOf("//"))).toEqual([]);
});
});
@@ -0,0 +1,72 @@
import type { Extension } from "@codemirror/state";
import { tags } from "@lezer/highlight";
import { shellLineCommentRanges } from "@/lib/editor/shellLineCommentRanges";
type EditorViewType = import("@codemirror/view").EditorView;
type DecorationSet = import("@codemirror/view").DecorationSet;
interface SyntaxNodeLike {
name: string;
parent: SyntaxNodeLike | null;
}
export const SHELL_LINE_COMMENT_CLASS = "cm-shell-line-comment";
interface ShellLineCommentHighlightDeps {
ViewPlugin: typeof import("@codemirror/view").ViewPlugin;
Decoration: typeof import("@codemirror/view").Decoration;
highlightingFor: typeof import("@codemirror/language").highlightingFor;
syntaxTree: typeof import("@codemirror/language").syntaxTree;
}
/** Reuses the active theme's comment colour so `//` matches what the SQL grammar gives `--`. */
export function shellLineCommentClass(state: import("@codemirror/state").EditorState, highlightingFor: ShellLineCommentHighlightDeps["highlightingFor"]): string {
const themeClass = highlightingFor(state, [tags.lineComment, tags.comment]);
return themeClass ? `${SHELL_LINE_COMMENT_CLASS} ${themeClass}` : SHELL_LINE_COMMENT_CLASS;
}
export function createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree }: ShellLineCommentHighlightDeps): Extension {
function buildDecorations(view: EditorViewType, className: string): DecorationSet {
const tree = syntaxTree(view.state);
const ranges = new Map<number, number>();
for (const visibleRange of view.visibleRanges) {
const from = view.state.doc.lineAt(visibleRange.from).from;
const to = view.state.doc.lineAt(visibleRange.to).to;
const text = view.state.doc.sliceString(from, to);
for (const range of shellLineCommentRanges(text)) {
const absoluteFrom = from + range.from;
const absoluteTo = from + range.to;
if (absoluteFrom >= visibleRange.to || absoluteTo <= visibleRange.from) continue;
let node: SyntaxNodeLike | null = tree.resolveInner(absoluteFrom, 1);
let excluded = false;
while (node) {
if (node.name === "BlockComment" || node.name === "String" || node.name === "QuotedIdentifier") {
excluded = true;
break;
}
node = node.parent;
}
if (!excluded) ranges.set(absoluteFrom, absoluteTo);
}
}
const decoration = Decoration.mark({ class: className });
return Decoration.set([...ranges].sort(([left], [right]) => left - right).map(([from, to]) => decoration.range(from, to)));
}
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
className: string;
constructor(view: EditorViewType) {
this.className = shellLineCommentClass(view.state, highlightingFor);
this.decorations = buildDecorations(view, this.className);
}
update(update: import("@codemirror/view").ViewUpdate) {
const className = shellLineCommentClass(update.state, highlightingFor);
if (!update.docChanged && !update.viewportChanged && className === this.className) return;
this.className = className;
this.decorations = buildDecorations(update.view, className);
}
},
{ decorations: (plugin) => plugin.decorations },
);
}
@@ -30,6 +30,21 @@ export function sqlSemanticHighlightTheme(EditorView: typeof import("@codemirror
});
}
// MongoDB and other shell-style editors keep the SQL grammar, so their `//` comments are decorated
// manually. The decoration carries the active theme's comment highlight class, so it only has to
// keep the SQL grammar's colours from leaking through on the tokens it wraps.
export function shellLineCommentTheme(EditorView: typeof import("@codemirror/view").EditorView): Extension {
return EditorView.theme({
".cm-shell-line-comment": {
fontStyle: "italic",
},
".cm-shell-line-comment *": {
color: "inherit !important",
fontStyle: "italic",
},
});
}
const SUPPORTS_COLOR_MIX = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", "color-mix(in oklch, black 50%, white)");
const SUPPORTS_OKLCH = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", "oklch(0.62 0.19 255)");
@@ -0,0 +1,20 @@
import type { DatabaseType } from "@/types/database";
const SQL_LINE_COMMENT_TOKEN = "--";
// Editors that speak a shell/script dialect instead of SQL keep their native line comment marker.
const LINE_COMMENT_TOKENS: Partial<Record<DatabaseType, string>> = {
mongodb: "//",
};
export function queryEditorLineCommentToken(dbType?: DatabaseType): string {
if (!dbType) return SQL_LINE_COMMENT_TOKEN;
return LINE_COMMENT_TOKENS[dbType] ?? SQL_LINE_COMMENT_TOKEN;
}
export function queryEditorCommentTokens(dbType?: DatabaseType) {
return {
line: queryEditorLineCommentToken(dbType),
block: { open: "/*", close: "*/" },
};
}
@@ -0,0 +1,59 @@
export interface ShellLineCommentRange {
from: number;
to: number;
}
/**
* Finds `//` line comments in shell-style text (the MongoDB editor keeps the SQL grammar for
* highlighting, so its comments are not tokenized by the parser). Quoted strings and block
* comments are skipped so markers such as `"https://host"` stay uncommented.
*/
export function shellLineCommentRanges(text: string, to = text.length): ShellLineCommentRange[] {
const ranges: ShellLineCommentRange[] = [];
const limit = Math.min(to, text.length);
let index = 0;
while (index < limit) {
const char = text[index];
if (char === '"' || char === "'" || char === "`") {
index = skipString(text, index, char);
continue;
}
if (char === "/" && text[index + 1] === "*") {
const end = text.indexOf("*/", index + 2);
index = end === -1 ? text.length : end + 2;
continue;
}
if (char === "/" && text[index + 1] === "/") {
let end = index + 2;
while (end < text.length && text[end] !== "\n" && text[end] !== "\r") end += 1;
ranges.push({ from: index, to: end });
index = end;
continue;
}
index += 1;
}
return ranges;
}
function skipString(text: string, start: number, quote: string): number {
let index = start + 1;
while (index < text.length) {
const char = text[index];
if (char === "\\") {
index += 2;
continue;
}
if (char === quote) return index + 1;
// Single- and double-quoted strings do not span lines; bail out so an unterminated quote
// does not swallow the rest of the document.
if ((quote === '"' || quote === "'") && (char === "\n" || char === "\r")) return index;
index += 1;
}
return index;
}