From 08349892e6f3e7358e78bf9e2ba131ad9f07ff40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 08:24:51 +0000 Subject: [PATCH 1/2] Add VS Code-style autocompletion to the code editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable CodeMirror's completion UI in the editable setup (read-only previews stay off) and wire per-language sources: - JS/TS/JSX/TSX: keep the built-in keyword/local completions and add scopeCompletionSource(globalThis) so browser globals and their members (console.log, JSON.parse…) complete like in VS Code. - HTML/CSS/Python/SQL: their lang packages already ship completion sources; enabling the UI is enough. - Languages without a completion source (legacy stream modes plus bare grammars like Java/C++/PHP/JSON/XML): a fallback source merges word-based suggestions from the document with curated per-language keyword lists, deduplicating words already offered as keywords. - Markdown prose stays quiet; fenced code blocks complete via their nested language. Style the suggest popup after VS Code (panel tokens, blue matched text, per-type symbol icon colors) in both themes, and swap the default key-emoji keyword glyph for a plain marker. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QAjpJTH4WCmyRXkMWLSosz --- package.json | 1 + pnpm-lock.yaml | 61 +++---- src/__tests__/completions.test.ts | 88 ++++++++++ src/app/globals.css | 103 ++++++++++++ src/components/Editor/Editor.tsx | 60 +++++-- src/components/Editor/completions.ts | 229 +++++++++++++++++++++++++++ 6 files changed, 497 insertions(+), 45 deletions(-) create mode 100644 src/__tests__/completions.test.ts create mode 100644 src/components/Editor/completions.ts diff --git a/package.json b/package.json index fc3a23a..6b060db 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.3", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 384cad8..7f45424 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@codemirror/autocomplete': + specifier: ^6.20.3 + version: 6.20.3 '@codemirror/lang-cpp': specifier: ^6.0.3 version: 6.0.3 @@ -61,7 +64,7 @@ importers: version: 1.19.0(next@16.2.9(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(wrangler@4.81.1) '@replit/codemirror-lang-svelte': specifier: ^6.0.0 - version: 6.0.0(@codemirror/autocomplete@6.20.1)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8) + version: 6.0.0(@codemirror/autocomplete@6.20.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8) '@supabase/supabase-js': specifier: ^2.100.0 version: 2.100.0 @@ -112,7 +115,7 @@ importers: version: 4.25.9(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) '@uiw/react-codemirror': specifier: ^4.25.8 - version: 4.25.8(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 4.25.8(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -567,8 +570,8 @@ packages: cpu: [x64] os: [win32] - '@codemirror/autocomplete@6.20.1': - resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} '@codemirror/commands@6.10.3': resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} @@ -4195,6 +4198,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -4461,18 +4465,6 @@ packages: utf-8-validate: optional: true - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -5364,7 +5356,7 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260409.1': optional: true - '@codemirror/autocomplete@6.20.1': + '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 @@ -5385,7 +5377,7 @@ snapshots: '@codemirror/lang-css@6.3.1': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@lezer/common': 1.5.1 @@ -5393,7 +5385,7 @@ snapshots: '@codemirror/lang-html@6.4.11': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/lang-css': 6.3.1 '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.3 @@ -5410,7 +5402,7 @@ snapshots: '@codemirror/lang-javascript@6.2.5': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.3 '@codemirror/lint': 6.9.5 '@codemirror/state': 6.6.0 @@ -5425,7 +5417,7 @@ snapshots: '@codemirror/lang-markdown@6.5.0': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 @@ -5443,7 +5435,7 @@ snapshots: '@codemirror/lang-python@6.2.1': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@lezer/common': 1.5.1 @@ -5451,7 +5443,7 @@ snapshots: '@codemirror/lang-sql@6.10.0': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@lezer/common': 1.5.1 @@ -5469,7 +5461,7 @@ snapshots: '@codemirror/lang-xml@6.1.0': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 '@codemirror/view': 6.40.0 @@ -6104,9 +6096,9 @@ snapshots: '@remirror/core-constants@3.0.0': {} - '@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.1)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8)': + '@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)(@lezer/common@1.5.1)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.8)': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/lang-css': 6.3.1 '@codemirror/lang-html': 6.4.11 '@codemirror/lang-javascript': 6.2.5 @@ -6530,7 +6522,7 @@ snapshots: '@supabase/phoenix': 0.4.0 '@types/ws': 8.18.1 tslib: 2.8.1 - ws: 8.20.0 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -7000,9 +6992,9 @@ snapshots: '@typescript-eslint/types': 8.57.2 eslint-visitor-keys: 5.0.1 - '@uiw/codemirror-extensions-basic-setup@4.25.8(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': + '@uiw/codemirror-extensions-basic-setup@4.25.8(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.10.3 '@codemirror/language': 6.12.3 '@codemirror/lint': 6.9.5 @@ -7024,14 +7016,14 @@ snapshots: '@codemirror/state': 6.6.0 '@codemirror/view': 6.40.0 - '@uiw/react-codemirror@4.25.8(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@uiw/react-codemirror@4.25.8(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@babel/runtime': 7.29.2 '@codemirror/commands': 6.10.3 '@codemirror/state': 6.6.0 '@codemirror/theme-one-dark': 6.1.3 '@codemirror/view': 6.40.0 - '@uiw/codemirror-extensions-basic-setup': 4.25.8(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) + '@uiw/codemirror-extensions-basic-setup': 4.25.8(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0) codemirror: 6.0.2 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -7386,7 +7378,7 @@ snapshots: codemirror@6.0.2: dependencies: - '@codemirror/autocomplete': 6.20.1 + '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.10.3 '@codemirror/language': 6.12.3 '@codemirror/lint': 6.9.5 @@ -9613,10 +9605,7 @@ snapshots: ws@8.18.0: {} - ws@8.20.0: {} - - ws@8.21.0: - optional: true + ws@8.21.0: {} y18n@5.0.8: {} diff --git a/src/__tests__/completions.test.ts b/src/__tests__/completions.test.ts new file mode 100644 index 0000000..1f02e43 --- /dev/null +++ b/src/__tests__/completions.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { + CompletionContext, + type CompletionResult, +} from "@codemirror/autocomplete"; + +import { + LANGUAGE_KEYWORDS, + wordAndKeywordCompletion, +} from "@/components/Editor/completions"; + +/** Runs the fallback source over a plain-text doc at `pos`. */ +function complete( + doc: string, + pos: number, + keywords: readonly string[] = [], + explicit = false, +): CompletionResult | null { + const source = wordAndKeywordCompletion(keywords); + const context = new CompletionContext( + EditorState.create({ doc }), + pos, + explicit, + ); + return source(context) as CompletionResult | null; +} + +function labels(result: CompletionResult | null): string[] { + return result?.options.map((o) => o.label) ?? []; +} + +describe("wordAndKeywordCompletion", () => { + it("suggests words already present in the document", () => { + const doc = "const saludo = 1\nsal"; + const result = complete(doc, doc.length); + expect(labels(result)).toContain("saludo"); + expect(labels(result)).toContain("const"); + }); + + it("merges keywords into the suggestions, typed as keywords", () => { + const result = complete("fu", 2, ["func", "return"]); + const func = result?.options.find((o) => o.label === "func"); + expect(func).toBeDefined(); + expect(func?.type).toBe("keyword"); + }); + + it("does not duplicate a keyword that also appears as a document word", () => { + const doc = "func main\nfu"; + const result = complete(doc, doc.length, ["func"]); + const occurrences = labels(result).filter((l) => l === "func"); + expect(occurrences).toHaveLength(1); + // The surviving entry is the keyword-typed one, not the plain word. + expect(result?.options.find((o) => o.label === "func")?.type).toBe( + "keyword", + ); + }); + + it("stays quiet when there is no word before the cursor", () => { + expect(complete("a + ", 4, ["return"])).toBeNull(); + }); + + it("offers keywords on explicit request even without a typed prefix", () => { + const result = complete("x ", 2, ["return"], true); + expect(labels(result)).toContain("return"); + }); + + it("completes from the token start so the typed prefix is replaced", () => { + const doc = "hello hel"; + const result = complete(doc, doc.length); + expect(result?.from).toBe(doc.length - 3); + }); +}); + +describe("LANGUAGE_KEYWORDS", () => { + it("covers a sensible sample of languages and words", () => { + expect(LANGUAGE_KEYWORDS.go).toContain("func"); + expect(LANGUAGE_KEYWORDS.rust).toContain("fn"); + expect(LANGUAGE_KEYWORDS.dockerfile).toContain("FROM"); + expect(LANGUAGE_KEYWORDS.java).toContain("class"); + }); + + it("has no duplicate entries within a language", () => { + for (const [language, words] of Object.entries(LANGUAGE_KEYWORDS)) { + expect(new Set(words).size, language).toBe(words!.length); + } + }); +}); diff --git a/src/app/globals.css b/src/app/globals.css index fe078cc..844230c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -344,6 +344,109 @@ body { background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='%23cccccc'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.072 8.024L5.715 3.667l.618-.62L11 7.716v.618L6.333 13l-.618-.619 4.357-4.357z'/%3E%3C/svg%3E"); } +/* ─── Autocomplete popup (VS Code-style suggest widget) ────────────────────── + The tooltip lives inside .cm-editor, so it inherits the code font. Panel + chrome comes from the shared elevated-surface tokens; symbol icon colors + approximate VS Code's codicon palette in both themes. */ +.cm-tooltip.cm-tooltip-autocomplete { + background: var(--panel-bg) !important; + border: 1px solid var(--panel-border) !important; + border-radius: 8px; + box-shadow: var(--popover-shadow); + overflow: hidden; + padding: 3px; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul { + font-family: inherit !important; + font-size: 12px !important; + max-height: 16em !important; + min-width: 14em; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul > li { + display: flex !important; + align-items: center; + padding: 2.5px 8px 2.5px 5px !important; + border-radius: 5px; + color: rgba(var(--ink-rgb), 0.72); + line-height: 1.4; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { + background: rgba(var(--ink-rgb), 0.08) !important; + color: var(--ink) !important; +} + +/* Letters that match what's typed — VS Code's blue highlight */ +.cm-tooltip-autocomplete .cm-completionMatchedText { + text-decoration: none !important; + color: #4fc1ff; + font-weight: 600; +} + +:root[data-theme="light"] .cm-tooltip-autocomplete .cm-completionMatchedText { + color: #0066bf; +} + +/* Optional signature/detail text after the label */ +.cm-tooltip-autocomplete .cm-completionDetail { + color: rgba(var(--ink-rgb), 0.4); + font-style: normal !important; + margin-left: 0.8em; +} + +/* Symbol icons: keep the library's glyphs, recolored per type like VS Code */ +.cm-tooltip-autocomplete .cm-completionIcon { + flex: none; + width: 1.2em; + margin-right: 7px; + opacity: 1 !important; + text-align: center; + color: rgba(var(--ink-rgb), 0.45); +} + +/* The default keyword glyph is a key emoji — swap it for a plain marker */ +.cm-completionIcon-keyword::after { + content: "k" !important; +} + +.cm-completionIcon-function::after, +.cm-completionIcon-method::after { + color: #b180d7; +} + +.cm-completionIcon-variable::after, +.cm-completionIcon-property::after, +.cm-completionIcon-interface::after { + color: #75beff; +} + +.cm-completionIcon-class::after, +.cm-completionIcon-type::after, +.cm-completionIcon-enum::after, +.cm-completionIcon-constant::after { + color: #ee9d28; +} + +:root[data-theme="light"] .cm-completionIcon-function::after, +:root[data-theme="light"] .cm-completionIcon-method::after { + color: #652d90; +} + +:root[data-theme="light"] .cm-completionIcon-variable::after, +:root[data-theme="light"] .cm-completionIcon-property::after, +:root[data-theme="light"] .cm-completionIcon-interface::after { + color: #007acc; +} + +:root[data-theme="light"] .cm-completionIcon-class::after, +:root[data-theme="light"] .cm-completionIcon-type::after, +:root[data-theme="light"] .cm-completionIcon-enum::after, +:root[data-theme="light"] .cm-completionIcon-constant::after { + color: #a35b00; +} + /* ─── Markdown WYSIWYG editor (TipTap / ProseMirror) ───────────────────────── Notion-like document styling for the rich Markdown view. Built from the theme "ink" tokens so it flips with light/dark automatically. The editor root carries diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index 919fcb3..1b335bc 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -58,6 +58,38 @@ const appendLineOnClickBelow = EditorView.domEventHandlers({ // Module-level cache so repeated language loads are instant const extensionCache = new Map(); +// Languages whose grammar ships no completion source of its own (legacy +// stream modes and bare lang-* grammars). They get VS Code-style word-based +// suggestions from the document plus the curated keyword lists in +// ./completions. Languages absent here either bring their own completions +// (javascript, html, css, python, sql…) or shouldn't complete at all +// (markdown prose). +const FALLBACK_COMPLETION_LANGUAGES = new Set([ + "java", "c", "cpp", "php", "json", "xml", "bash", "yaml", "go", "rust", + "csharp", "ruby", "swift", "kotlin", "dart", "scala", "groovy", "lua", + "haskell", "erlang", "r", "powershell", "toml", "scss", "dockerfile", +]); + +// javascript()'s LanguageSupport already registers keyword/snippet completions +// and scope-aware local completions; scopeCompletionSource(globalThis) adds +// the runtime's ambient globals (console., document., JSON. …) on top, which +// is the closest we get to VS Code's IntelliSense without a language server. +async function loadJavaScript(config?: { + jsx?: boolean; + typescript?: boolean; +}): Promise { + const { javascript, scopeCompletionSource } = await import( + "@codemirror/lang-javascript" + ); + const support = javascript(config); + return [ + support, + support.language.data.of({ + autocomplete: scopeCompletionSource(globalThis), + }), + ]; +} + async function loadExtension(language: string): Promise { const cached = extensionCache.get(language); if (cached) return cached; @@ -66,23 +98,19 @@ async function loadExtension(language: string): Promise { switch (language) { case "javascript": { - const { javascript } = await import("@codemirror/lang-javascript"); - extensions = [javascript()]; + extensions = await loadJavaScript(); break; } case "typescript": { - const { javascript } = await import("@codemirror/lang-javascript"); - extensions = [javascript({ typescript: true })]; + extensions = await loadJavaScript({ typescript: true }); break; } case "tsx": { - const { javascript } = await import("@codemirror/lang-javascript"); - extensions = [javascript({ jsx: true, typescript: true })]; + extensions = await loadJavaScript({ jsx: true, typescript: true }); break; } case "jsx": { - const { javascript } = await import("@codemirror/lang-javascript"); - extensions = [javascript({ jsx: true })]; + extensions = await loadJavaScript({ jsx: true }); break; } case "svelte": { @@ -320,6 +348,20 @@ async function loadExtension(language: string): Promise { extensions = []; } + if (FALLBACK_COMPLETION_LANGUAGES.has(language) && extensions.length > 0) { + const { wordAndKeywordCompletion, LANGUAGE_KEYWORDS } = await import( + "./completions" + ); + const lang = extensions[0]; + const base = lang instanceof LanguageSupport ? lang.language : (lang as Language); + extensions = [ + ...extensions, + base.data.of({ + autocomplete: wordAndKeywordCompletion(LANGUAGE_KEYWORDS[language]), + }), + ]; + } + extensionCache.set(language, extensions); return extensions; } @@ -396,7 +438,7 @@ const EDIT_SETUP = { highlightActiveLine: true, bracketMatching: true, closeBrackets: true, - autocompletion: false, + autocompletion: true, foldGutter: false, // handled manually via customFoldGutter extension indentOnInput: true, tabSize: 2, diff --git a/src/components/Editor/completions.ts b/src/components/Editor/completions.ts new file mode 100644 index 0000000..b638459 --- /dev/null +++ b/src/components/Editor/completions.ts @@ -0,0 +1,229 @@ +import { + completeAnyWord, + type Completion, + type CompletionResult, + type CompletionSource, +} from "@codemirror/autocomplete"; + +/** + * VS Code-style fallback completion for languages whose grammar ships no + * completion source of its own: the document's words ("word based + * suggestions") merged with an optional curated keyword list. Words already + * present as keywords are dropped so a keyword typed in the document doesn't + * show up twice. + */ +export function wordAndKeywordCompletion( + keywords: readonly string[] = [], +): CompletionSource { + if (keywords.length === 0) return completeAnyWord; + + const keywordOptions: Completion[] = keywords.map((label) => ({ + label, + type: "keyword", + })); + const keywordSet = new Set(keywords); + + return (context) => { + // completeAnyWord resolves the token to complete and already handles the + // "no word before the cursor and not explicitly requested" case (null). + // It is synchronous even though the CompletionSource type allows promises. + const words = completeAnyWord(context) as CompletionResult | null; + if (!words) return null; + return { + ...words, + options: [ + ...keywordOptions, + ...words.options.filter((option) => !keywordSet.has(option.label)), + ], + }; + }; +} + +/** + * Curated keyword lists (reserved words plus a handful of ubiquitous builtins + * and literals) for the languages that get the fallback source above. Keys are + * `LanguageId`s from `src/lib/constants/languages.ts`; languages absent here + * (YAML, TOML, JSON, XML, SCSS…) get word-based suggestions only. + */ +export const LANGUAGE_KEYWORDS: Partial> = { + java: [ + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", + "class", "const", "continue", "default", "do", "double", "else", "enum", + "extends", "final", "finally", "float", "for", "if", "implements", + "import", "instanceof", "int", "interface", "long", "native", "new", + "package", "private", "protected", "public", "record", "return", "sealed", + "short", "static", "strictfp", "super", "switch", "synchronized", "this", + "throw", "throws", "transient", "try", "var", "void", "volatile", "while", + "yield", "true", "false", "null", "String", "System", + ], + c: [ + "auto", "break", "case", "char", "const", "continue", "default", "do", + "double", "else", "enum", "extern", "float", "for", "goto", "if", + "inline", "int", "long", "register", "restrict", "return", "short", + "signed", "sizeof", "static", "struct", "switch", "typedef", "union", + "unsigned", "void", "volatile", "while", "bool", "true", "false", "NULL", + "include", "define", "printf", "malloc", "free", "size_t", + ], + cpp: [ + "alignas", "alignof", "auto", "bool", "break", "case", "catch", "char", + "class", "concept", "const", "consteval", "constexpr", "const_cast", + "continue", "co_await", "co_return", "co_yield", "decltype", "default", + "delete", "do", "double", "dynamic_cast", "else", "enum", "explicit", + "export", "extern", "false", "float", "for", "friend", "goto", "if", + "inline", "int", "long", "mutable", "namespace", "new", "noexcept", + "nullptr", "operator", "private", "protected", "public", + "reinterpret_cast", "requires", "return", "short", "signed", "sizeof", + "static", "static_cast", "struct", "switch", "template", "this", "throw", + "true", "try", "typedef", "typeid", "typename", "union", "unsigned", + "using", "virtual", "void", "volatile", "while", "include", "define", + "std", "string", "vector", "cout", "cin", "endl", "size_t", + ], + php: [ + "abstract", "and", "array", "as", "break", "callable", "case", "catch", + "class", "clone", "const", "continue", "declare", "default", "do", "echo", + "else", "elseif", "empty", "enum", "extends", "final", "finally", "fn", + "for", "foreach", "function", "global", "goto", "if", "implements", + "include", "include_once", "instanceof", "insteadof", "interface", + "isset", "list", "match", "namespace", "new", "or", "print", "private", + "protected", "public", "readonly", "require", "require_once", "return", + "static", "switch", "this", "throw", "trait", "try", "unset", "use", + "var", "while", "xor", "yield", "true", "false", "null", + ], + bash: [ + "if", "then", "else", "elif", "fi", "for", "while", "until", "do", + "done", "case", "esac", "in", "function", "select", "time", "coproc", + "echo", "printf", "read", "cd", "pwd", "exit", "return", "export", + "local", "declare", "unset", "shift", "source", "alias", "eval", "exec", + "set", "test", "trap", "sudo", "grep", "sed", "awk", "curl", + ], + go: [ + "break", "case", "chan", "const", "continue", "default", "defer", "else", + "fallthrough", "for", "func", "go", "goto", "if", "import", "interface", + "map", "package", "range", "return", "select", "struct", "switch", + "type", "var", "append", "cap", "close", "copy", "delete", "len", "make", + "new", "panic", "recover", "true", "false", "nil", "iota", "int", + "int64", "uint", "byte", "rune", "float64", "string", "bool", "error", + ], + rust: [ + "as", "async", "await", "break", "const", "continue", "crate", "dyn", + "else", "enum", "extern", "false", "fn", "for", "if", "impl", "in", + "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", + "self", "Self", "static", "struct", "super", "trait", "true", "type", + "unsafe", "use", "where", "while", "String", "Vec", "Option", "Some", + "None", "Result", "Ok", "Err", "Box", "i32", "i64", "u32", "u64", "f64", + "usize", "isize", "bool", "str", "println", + ], + csharp: [ + "abstract", "as", "async", "await", "base", "bool", "break", "byte", + "case", "catch", "char", "checked", "class", "const", "continue", + "decimal", "default", "delegate", "do", "double", "else", "enum", + "event", "explicit", "extern", "false", "finally", "fixed", "float", + "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", + "internal", "is", "lock", "long", "namespace", "new", "null", "object", + "operator", "out", "override", "params", "private", "protected", + "public", "readonly", "record", "ref", "return", "sbyte", "sealed", + "short", "sizeof", "static", "string", "struct", "switch", "this", + "throw", "true", "try", "typeof", "uint", "ulong", "unsafe", "ushort", + "using", "var", "virtual", "void", "volatile", "while", "yield", + "Console", + ], + ruby: [ + "alias", "and", "begin", "break", "case", "class", "def", "do", "else", + "elsif", "end", "ensure", "false", "for", "if", "in", "module", "next", + "nil", "not", "or", "redo", "rescue", "retry", "return", "self", + "super", "then", "true", "undef", "unless", "until", "when", "while", + "yield", "attr_accessor", "attr_reader", "attr_writer", "require", + "require_relative", "puts", "print", "lambda", "proc", "new", "each", + ], + swift: [ + "actor", "any", "as", "associatedtype", "async", "await", "break", + "case", "catch", "class", "continue", "default", "defer", "deinit", + "do", "else", "enum", "extension", "fallthrough", "false", "fileprivate", + "for", "func", "guard", "if", "import", "in", "init", "inout", + "internal", "is", "let", "nil", "open", "operator", "private", + "protocol", "public", "repeat", "rethrows", "return", "self", "Self", + "some", "static", "struct", "subscript", "super", "switch", "throw", + "throws", "true", "try", "typealias", "var", "where", "while", "String", + "Int", "Double", "Bool", "print", + ], + kotlin: [ + "abstract", "annotation", "as", "break", "by", "catch", "class", + "companion", "const", "constructor", "continue", "crossinline", "data", + "do", "else", "enum", "expect", "external", "false", "final", "finally", + "for", "fun", "get", "if", "import", "in", "infix", "init", "inline", + "inner", "interface", "internal", "is", "lateinit", "null", "object", + "open", "operator", "out", "override", "package", "private", + "protected", "public", "reified", "return", "sealed", "set", "super", + "suspend", "tailrec", "this", "throw", "true", "try", "typealias", + "val", "var", "vararg", "when", "where", "while", "println", + ], + dart: [ + "abstract", "as", "assert", "async", "await", "base", "break", "case", + "catch", "class", "const", "continue", "covariant", "default", + "deferred", "do", "dynamic", "else", "enum", "export", "extends", + "extension", "external", "factory", "false", "final", "finally", "for", + "get", "hide", "if", "implements", "import", "in", "interface", "is", + "late", "library", "mixin", "new", "null", "on", "operator", "part", + "required", "rethrow", "return", "sealed", "set", "show", "static", + "super", "switch", "sync", "this", "throw", "true", "try", "typedef", + "var", "void", "when", "while", "with", "yield", "String", "int", + "double", "bool", "List", "Map", "Future", "print", + ], + scala: [ + "abstract", "case", "catch", "class", "def", "do", "else", "enum", + "extends", "false", "final", "finally", "for", "given", "if", + "implicit", "import", "lazy", "match", "new", "null", "object", + "override", "package", "private", "protected", "return", "sealed", + "super", "then", "this", "throw", "trait", "true", "try", "type", + "using", "val", "var", "while", "with", "yield", "String", "Int", + "Boolean", "List", "Map", "Option", "Some", "None", "println", + ], + groovy: [ + "abstract", "as", "assert", "break", "case", "catch", "class", "const", + "continue", "def", "default", "do", "else", "enum", "extends", "false", + "final", "finally", "for", "goto", "if", "implements", "import", "in", + "instanceof", "interface", "new", "null", "package", "private", + "protected", "public", "return", "static", "super", "switch", "this", + "throw", "throws", "trait", "true", "try", "var", "while", "println", + ], + lua: [ + "and", "break", "do", "else", "elseif", "end", "false", "for", + "function", "goto", "if", "in", "local", "nil", "not", "or", "repeat", + "return", "then", "true", "until", "while", "print", "pairs", "ipairs", + "pcall", "require", "table", "string", "math", "type", "tostring", + "tonumber", "self", + ], + haskell: [ + "case", "class", "data", "default", "deriving", "do", "else", "foreign", + "if", "import", "in", "infix", "infixl", "infixr", "instance", "let", + "module", "newtype", "of", "then", "type", "where", "map", "filter", + "foldr", "foldl", "return", "pure", "fmap", "Maybe", "Just", "Nothing", + "Either", "Left", "Right", "IO", "String", "Int", "Bool", "True", + "False", "otherwise", "putStrLn", + ], + erlang: [ + "after", "and", "andalso", "band", "begin", "bnot", "bor", "bsl", + "bsr", "bxor", "case", "catch", "cond", "div", "end", "fun", "if", + "not", "of", "or", "orelse", "receive", "rem", "try", "when", "xor", + "module", "export", "import", "spawn", "true", "false", "ok", "error", + ], + r: [ + "if", "else", "repeat", "while", "function", "for", "in", "next", + "break", "TRUE", "FALSE", "NULL", "Inf", "NaN", "NA", "library", + "require", "print", "paste", "vector", "list", "matrix", "return", + "lapply", "sapply", "length", "names", "summary", + ], + powershell: [ + "begin", "break", "catch", "class", "continue", "do", "dynamicparam", + "else", "elseif", "end", "enum", "exit", "filter", "finally", "for", + "foreach", "function", "hidden", "if", "in", "param", "process", + "return", "static", "switch", "throw", "trap", "try", "until", "using", + "while", "Write-Host", "Write-Output", "Get-ChildItem", "Get-Item", + "Set-Location", "ForEach-Object", "Where-Object", "Select-Object", + ], + dockerfile: [ + "FROM", "RUN", "CMD", "LABEL", "MAINTAINER", "EXPOSE", "ENV", "ADD", + "COPY", "ENTRYPOINT", "VOLUME", "USER", "WORKDIR", "ARG", "ONBUILD", + "STOPSIGNAL", "HEALTHCHECK", "SHELL", "AS", + ], +}; From 64625f07ae7a7f7c896e784a89a4a0ce40103675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Mart=C3=ADnez?= Date: Thu, 2 Jul 2026 10:47:58 +0200 Subject: [PATCH 2/2] feat(Editor): add Tab key support for accepting completions --- src/components/Editor/Editor.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index 1b335bc..9ddb0ec 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -3,8 +3,10 @@ import { useState, useEffect, type CSSProperties } from "react"; import CodeMirror, { EditorView, + keymap, type ReactCodeMirrorRef, } from "@uiw/react-codemirror"; +import { acceptCompletion } from "@codemirror/autocomplete"; import { vscodeDark, vscodeLight } from "@uiw/codemirror-theme-vscode"; import { foldGutter, @@ -12,7 +14,7 @@ import { LanguageDescription, type Language, } from "@codemirror/language"; -import type { Extension } from "@codemirror/state"; +import { Prec, type Extension } from "@codemirror/state"; import { useTheme } from "@/hooks/useTheme"; import { vscodeDarkMarkdown, @@ -55,6 +57,16 @@ const appendLineOnClickBelow = EditorView.domEventHandlers({ }, }); +// Accept the highlighted completion with Tab, like VS Code. Prec.highest is +// required because @uiw/react-codemirror enables indentWithTab by default and +// unshifts it to the front of the keymap stack; without boosting precedence, +// that binding would swallow Tab (indenting) before this handler runs. +// acceptCompletion returns false when no suggestion popup is open, so Tab falls +// through to its normal indentation behaviour the rest of the time. +const acceptCompletionOnTab = Prec.highest( + keymap.of([{ key: "Tab", run: acceptCompletion }]), +); + // Module-level cache so repeated language loads are instant const extensionCache = new Map(); @@ -525,7 +537,12 @@ export function Editor({ extensions={ readOnly ? extensions - : [...extensions, customFoldGutter, appendLineOnClickBelow] + : [ + acceptCompletionOnTab, + ...extensions, + customFoldGutter, + appendLineOnClickBelow, + ] } editable={!readOnly} readOnly={readOnly}