Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
308 changes: 179 additions & 129 deletions apps/web/src/components/code-editor.tsx
Original file line number Diff line number Diff line change
@@ -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<string, () => 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<HotkeyMap>) {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
function baseExtensions(hotkeysRef: React.RefObject<HotkeyMap>): Extension[] {
return [
buildHotkeyExtension(hotkeysRef),
basicSetup,
indentUnit.of(INDENT),
keymap.of([indentWithTab]),
theme,
autocompletion({
activateOnTyping: false,
override: [],
}),
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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",
Comment thread
snelusha marked this conversation as resolved.
className,
}: CodeEditorProps) {
const [highlighted, setHighlighted] = React.useState("");
const highlighterRef = React.useRef<HighlighterGeneric<
BundledLanguage,
BundledTheme
> | null>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const preRef = React.useRef<HTMLPreElement>(null);

const renderHighlight = React.useCallback(
(
code: string,
hl?: HighlighterGeneric<BundledLanguage, BundledTheme> | null,
) => {
const instance = hl || highlighterRef.current;
if (!instance) return;

try {
const html = instance.codeToHtml(code || " ", {
lang: language,
theme: "github-light",
});
const inner = html
.replace(/^<pre[^>]*><code[^>]*>/, "")
.replace(/<\/code><\/pre>$/, "");
setHighlighted(inner);
} catch {
setHighlighted(escapeHtml(code));
}
},
[language],
);
const parentRef = React.useRef<HTMLDivElement>(null);
const editorRef = React.useRef<ShikiEditor | null>(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;

Comment thread
snelusha marked this conversation as resolved.
// 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,
Comment thread
warunalakshitha marked this conversation as resolved.
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]);
}, []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<HTMLTextAreaElement>) => {
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 (
<div
ref={parentRef}
className={cn(
"text-[13px] relative flex overflow-hidden h-full min-h-37.5",
"relative overflow-hidden h-full min-h-37.5 cm-editor-host",
className,
)}
>
<div className="relative grow">
<pre
ref={preRef}
aria-hidden="true"
className={cn(sharedClasses, "z-10 pointer-events-none no-scrollbar")}
// biome-ignore lint/security/noDangerouslySetInnerHtml: content is sanitized via Shiki's HTML escaping
dangerouslySetInnerHTML={{ __html: `${highlighted}\n` }}
/>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange?.(e.target.value)}
onScroll={syncScroll}
onKeyDown={handleKeyDown}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className={cn(
sharedClasses,
"z-20 bg-transparent text-transparent caret-blue-500 outline-none resize-none no-scrollbar",
)}
/>
</div>
</div>
/>
);
}
Loading