diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index cc994111b4..c5c86cf366 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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; diff --git a/apps/desktop/src/lib/__tests__/editor/codemirrorShellLineCommentHighlight.spec.ts b/apps/desktop/src/lib/__tests__/editor/codemirrorShellLineCommentHighlight.spec.ts new file mode 100644 index 0000000000..96e65b28b5 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/codemirrorShellLineCommentHighlight.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorLineComment.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorLineComment.spec.ts new file mode 100644 index 0000000000..329e922ff5 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorLineComment.spec.ts @@ -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"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/shellLineCommentRanges.spec.ts b/apps/desktop/src/lib/__tests__/editor/shellLineCommentRanges.spec.ts new file mode 100644 index 0000000000..5731286d03 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/shellLineCommentRanges.spec.ts @@ -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([]); + }); +}); diff --git a/apps/desktop/src/lib/editor/codemirrorShellLineCommentHighlight.ts b/apps/desktop/src/lib/editor/codemirrorShellLineCommentHighlight.ts new file mode 100644 index 0000000000..383e3cb474 --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorShellLineCommentHighlight.ts @@ -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(); + 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 }, + ); +} diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts index 92c9773d10..368d86dd60 100644 --- a/apps/desktop/src/lib/editor/editorThemes.ts +++ b/apps/desktop/src/lib/editor/editorThemes.ts @@ -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)"); diff --git a/apps/desktop/src/lib/editor/queryEditorLineComment.ts b/apps/desktop/src/lib/editor/queryEditorLineComment.ts new file mode 100644 index 0000000000..39edfd160d --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorLineComment.ts @@ -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> = { + 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: "*/" }, + }; +} diff --git a/apps/desktop/src/lib/editor/shellLineCommentRanges.ts b/apps/desktop/src/lib/editor/shellLineCommentRanges.ts new file mode 100644 index 0000000000..2c9d5f88a6 --- /dev/null +++ b/apps/desktop/src/lib/editor/shellLineCommentRanges.ts @@ -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; +}