mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(mongodb): use line comments in editor
This commit is contained in:
@@ -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;
|
||||
|
||||
+60
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user