diff --git a/apps/web/package.json b/apps/web/package.json index a2cd783..e7e3a3e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,18 +11,27 @@ }, "dependencies": { "@base-ui/react": "^1.3.0", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.6", + "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-router": "^1.168.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "immer": "^11.1.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hotkeys-hook": "^5.2.4", - "shadcn": "^4.1.2", + "shadcn": "^4.2.0", "shiki": "^4.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -37,7 +46,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "globals": "^17.4.0", + "style-mod": "^4.1.3", "typescript": "~6.0.2", - "vite": "^8.0.4" + "vite": "^8.0.7" } } diff --git a/apps/web/src/components/code-editor.tsx b/apps/web/src/components/code-editor.tsx index f40e04b..070ac4e 100644 --- a/apps/web/src/components/code-editor.tsx +++ b/apps/web/src/components/code-editor.tsx @@ -1,164 +1,214 @@ import * as React from "react"; -import { createHighlighter } from "shiki"; +import { basicSetup } from "codemirror"; +import { Compartment, Prec } from "@codemirror/state"; +import { StreamLanguage, indentUnit } from "@codemirror/language"; +import { autocompletion } from "@codemirror/autocomplete"; +import { EditorView, keymap } from "@codemirror/view"; +import { indentWithTab } from "@codemirror/commands"; +import { clike } from "@codemirror/legacy-modes/mode/clike"; +import { Vim, vim } from "@replit/codemirror-vim"; + +import { ShikiEditor } from "@/components/shiki-editor"; + +import { useEditorStore } from "@/stores/editor-store"; +import { useFileTreeActions } from "@/stores/file-tree-store"; import { cn } from "@/lib/utils"; -import type { BundledLanguage, BundledTheme, HighlighterGeneric } from "shiki"; +import type { KeyBinding } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; + +export type EditorLanguage = "ballerina" | "toml" | "text"; + +type HotkeyMap = Record void>; interface CodeEditorProps { value?: string; onChange?: (value: string) => void; - language?: string; + hotkeys?: HotkeyMap; + language?: EditorLanguage; className?: string; } -const sharedClasses = - "p-4 leading-[22.5px] font-sans whitespace-pre overflow-auto absolute inset-0 box-border [tab-size:2]"; +const INDENT = " "; + +// This is a hack to bring smart indentation for Ballerina +// since there's no official CodeMirror support +const ballerinaMode = StreamLanguage.define( + clike({ + name: "ballerina", + }), +); + +function buildHotkeyExtension(hotkeysRef: React.RefObject) { + const bindings: KeyBinding[] = Object.keys(hotkeysRef.current ?? {}).map( + (key) => ({ + key, + run: () => { + hotkeysRef.current?.[key]?.(); + return true; + }, + }), + ); + + return Prec.highest(keymap.of(bindings)); +} -function escapeHtml(html: string) { - return html - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); +function baseExtensions(hotkeysRef: React.RefObject): Extension[] { + return [ + buildHotkeyExtension(hotkeysRef), + basicSetup, + indentUnit.of(INDENT), + keymap.of([indentWithTab]), + theme, + autocompletion({ + activateOnTyping: false, + override: [], + }), + ]; } +const theme = EditorView.theme({ + "&": { + fontSize: "12.5px", + height: "100%", + }, + ".cm-scroller": { + fontFamily: "var(--font-sans), ui-monospace, monospace", + overflow: "auto", + scrollbarWidth: "none", + msOverflowStyle: "none", + }, + ".cm-scroller::-webkit-scrollbar": { + display: "none", + }, + ".cm-content": { + paddingTop: "1rem", + lineHeight: "180%", + }, + ".cm-line": { + lineHeight: "inherit", + }, + ".cm-gutters": { + paddingLeft: "0.5rem", + backgroundColor: "transparent", + border: "none", + color: "var(--muted-foreground)", + userSelect: "none", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, + ".cm-activeLine": { + backgroundColor: "transparent", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "rgba(59, 130, 246, 0.1) !important", + }, + ".cm-matchingBracket, .cm-nonmatchingBracket": { + outline: "none", + borderRadius: "0", + }, + ".cm-vim-panel": { + backgroundColor: "var(--background)", + color: "var(--foreground)", + }, + ".cm-vim-panel input": { + fontFamily: "var(--font-sans), ui-monospace, monospace !important", + }, + ".cm-vim-message": { + color: "var(--muted-foreground) !important", + }, +}); + export function CodeEditor({ - value = "", + value, onChange, + hotkeys = {}, language = "ballerina", className, }: CodeEditorProps) { - const [highlighted, setHighlighted] = React.useState(""); - const highlighterRef = React.useRef | null>(null); - const textareaRef = React.useRef(null); - const preRef = React.useRef(null); - - const renderHighlight = React.useCallback( - ( - code: string, - hl?: HighlighterGeneric | null, - ) => { - const instance = hl || highlighterRef.current; - if (!instance) return; - - try { - const html = instance.codeToHtml(code || " ", { - lang: language, - theme: "github-light", - }); - const inner = html - .replace(/^]*>]*>/, "") - .replace(/<\/code><\/pre>$/, ""); - setHighlighted(inner); - } catch { - setHighlighted(escapeHtml(code)); - } - }, - [language], - ); + const parentRef = React.useRef(null); + const editorRef = React.useRef(null); + + const languageCompartment = React.useRef(new Compartment()); + const vimCompartment = React.useRef(new Compartment()); + + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + + const hotkeysRef = React.useRef(hotkeys); + hotkeysRef.current = hotkeys; + const vimEnabled = useEditorStore((s) => s.editorMode) === "vim"; + + const { saveFile } = useFileTreeActions(); + + const saveFileRef = React.useRef(saveFile); + saveFileRef.current = saveFile; + + // biome-ignore lint/correctness/useExhaustiveDependencies: editor is recreated only on lang change; value is synced separately React.useEffect(() => { - let isMounted = true; - - async function initShiki() { - try { - const hl = await createHighlighter({ - themes: ["github-light"], - langs: ["ballerina", "toml"], - }); - - if (isMounted) { - highlighterRef.current = hl; - renderHighlight(textareaRef.current?.value ?? "", hl); - } - } catch { - if (isMounted) - setHighlighted(escapeHtml(textareaRef.current?.value ?? "")); - } - } - - initShiki(); + const parent = parentRef.current; + if (!parent) return; + + const editor = new ShikiEditor({ + parent, + doc: value, + lang: language, + themes: { + light: "github-light", + }, + defaultColor: "light", + themeStyle: "cm", + onUpdate: (update) => { + if (update.docChanged) + onChangeRef.current?.(update.state.doc.toString()); + }, + extensions: [ + ...baseExtensions(hotkeysRef), + languageCompartment.current.of( + language === "ballerina" ? ballerinaMode : [], + ), + vimCompartment.current.of(vimEnabled ? vim() : []), + ], + }); + + editorRef.current = editor; + return () => { - isMounted = false; - highlighterRef.current?.dispose(); - highlighterRef.current = null; + editorRef.current?.destroy(); + editorRef.current = null; }; - }, [renderHighlight]); + }, []); React.useEffect(() => { - renderHighlight(value); - }, [value, renderHighlight]); - - const syncScroll = React.useCallback(() => { - if (textareaRef.current && preRef.current) { - preRef.current.scrollTop = textareaRef.current.scrollTop; - preRef.current.scrollLeft = textareaRef.current.scrollLeft; - } - }, []); + const editor = editorRef.current; + if (!editor) return; + editor.reconfigure( + languageCompartment.current, + language === "ballerina" ? ballerinaMode : [], + ); + }, [language]); - const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { - if (e.key !== "Tab") return; - - e.preventDefault(); - const target = e.currentTarget; - const { selectionStart, selectionEnd, value: currentValue } = target; - - const newValue = - currentValue.slice(0, selectionStart) + - " " + - currentValue.slice(selectionEnd); - onChange?.(newValue); - - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.setSelectionRange( - selectionStart + 4, - selectionStart + 4, - ); - } - }, 0); - }, - [onChange], - ); + React.useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + editor.reconfigure(vimCompartment.current, vimEnabled ? vim() : []); + }, [vimEnabled]); + + React.useEffect(() => { + Vim.defineEx("write", "w", () => saveFileRef.current?.()); + }, []); return (
-
-