Skip to content

Preview — CodeMirror 6 editor (line numbers, syntax highlighting, auto-save) #728

Description

@jeonghun-jj-lee

Preview — CodeMirror 6 editor (line numbers, syntax highlighting, auto-save)

Important

Problem — The current raw editor is a bare <textarea> with no line numbers, no syntax highlighting, and no visual affordance that it is a code editor. It looks unprofessional and is painful for editing TeX source, config files, or any structured text.

Approach — Replace the <textarea> RawEditor with a CodeMirror 6 editor component. CodeMirror 6 provides line numbers, syntax highlighting, a gutter, theme integration, and a proper editing experience (~150KB gzipped). Add language modes for LaTeX (.tex, .bib, .tikz, .sty, .cls), Markdown (.md), and plain text (.txt, .log). Wire auto-save (1s debounce + Cmd+S) with a design-token save indicator. Support a read-only mode for files that should not be edited (.log).

Scope — in: CodeMirror 6 integration, line numbers, syntax highlighting, language modes (LaTeX, Markdown, plain text), auto-save with status indicator, read-only mode, theme integration. out: vim/emacs keybindings (can be added later via CM6 extensions), minimap, multiple cursors, diff decorations (those belong in Files Changed if ever added).

Acceptance Criteria

  • codemirror, @codemirror/lang-markdown, @codemirror/lang-javascript, and @codemirror/legacy-modes (for LaTeX) are added as dependencies
  • A CodeMirrorEditor component accepts: file content (string), language hint (extension string), read-only flag, onSave callback, onChange callback
  • Line numbers are always visible in the gutter
  • Syntax highlighting works for: LaTeX (.tex, .bib, .tikz, .sty, .cls), Markdown (.md), plain text (.txt, .log), and any other language CM6 supports out of the box
  • Theme integrates with the V2 design token system — editor background uses --v2-background-bg-base, text uses --v2-text-text-base, gutter uses --v2-background-bg-layer-01, line numbers use --v2-text-text-muted. These are CSS custom properties from the harmoniqs theme JSON (packages/ui/src/theme/themes/harmoniqs.json), not --amc-* tokens (those are for the dashboard widget SDK only)
  • Dark/light theme switches correctly when the app theme changes
  • Auto-save fires 1 second after the last edit via the SDK's typed sdk().client.file.write({ path, content }) method (not raw fetch — the current SessionPreviewTab uses raw fetch to /file/write, but the SDK provides a typed wrapper at sdk.gen.ts:1957); Cmd+S triggers an immediate save
  • Save indicator: "Saving…" in --status-running (yellow), "Saved" in --status-done (green), clears after 2 seconds
  • Read-only mode: line numbers and syntax highlighting are present, but editing is disabled (for .log files)
  • The editor fills its container and is responsive to panel resizing
  • Tab key inserts the appropriate whitespace (2 spaces for most files, configurable)
  • Standard keybindings work: Cmd+Z (undo), Cmd+Shift+Z (redo), Cmd+A (select all), Cmd+C/V/X (copy/paste/cut) — the editor intercepts these before the app's global command system

Testing Decisions

  • Unit test the language-mode dispatch (extension string → CM6 language extension)
  • Unit test the auto-save debounce timing and Cmd+S immediate-save behavior (mock the file write API)
  • Unit test read-only mode — verify edits are rejected
  • Integration test: mount the editor with fixture content, verify line numbers render and syntax tokens are applied
  • Theme test: verify CSS custom properties are consumed (snapshot or computed-style check)

Key Decisions

CodeMirror 6, not Monaco. Monaco is VS Code's editor but is ~3MB and designed for standalone use — it bundles its own worker, layout engine, and theme system. CodeMirror 6 is modular (~150KB for our use case), integrates with any DOM layout, and its theme system maps cleanly to CSS custom properties. It is the right fit for an embedded editor in a panel.

LaTeX via @codemirror/legacy-modes. CM6 does not have a first-party LaTeX language package. The stex (Structured TeX) mode from CodeMirror 5 is available via @codemirror/legacy-modes/mode/stex and provides adequate syntax highlighting for LaTeX, BibTeX, and TikZ. If a better CM6-native LaTeX package emerges, it is a drop-in replacement.

Auto-save, not explicit save. Matches the existing SessionPreviewTab pattern. The 1-second debounce prevents hammering the server on every keystroke. Cmd+S is the escape hatch for immediate save. Use the SDK's typed file.write method rather than the raw fetch('/file/write', ...) pattern the existing code uses.

Bundle size. CodeMirror 6 with line numbers, syntax highlighting, and the stex legacy mode adds ~150KB gzipped. Combined with pdfjs-dist in slice 3 (~800KB), the total addition across both slices is ~1MB. This is within acceptable bounds for a panel that replaces a bare textarea with a professional editor + PDF viewer.

Design-token theme, not a canned CM6 theme. Build a custom CM6 theme that reads from --v2-* CSS custom properties (the V2 design token system defined in packages/ui/src/theme/themes/harmoniqs.json). Because the theme references var(--v2-*), it auto-switches between light and dark mode when ThemeProvider updates — no CM6 Compartment reconfiguration needed. Note: --amc-* tokens are for the dashboard widget SDK only, not the app.

Constraints & Invariants

  • The editor never writes to the filesystem without the user having typed (no phantom saves on mount)
  • Read-only mode is enforced by CM6's EditorState.readOnly — not a CSS pointer-events hack
  • The editor never captures Cmd+S when it is not focused
  • Theme changes apply without remounting the editor (CM6's Compartment system)

Prior Art

  • RawEditor in session-preview-tab.tsx — the bare <textarea> being replaced
  • Auto-save + POST /file/write — existing pattern in SessionPreviewTab
  • @codemirror/merge — considered for diff editing in Files Changed; not needed for this slice but the CM6 dependency is shared

Source

harmoniqs/opencode — new CodeMirrorEditor component in packages/app/src/components/, consumed by the renderer dispatch (slice 2)

Implementation Plan

Files to create

File Repo Purpose
packages/app/src/components/codemirror-editor.tsx opencode CodeMirrorEditor component: CM6 with line numbers, syntax highlighting, auto-save
packages/app/src/components/codemirror-theme.ts opencode Custom CM6 theme consuming --v2-* design tokens
packages/app/src/utils/codemirror-languages.ts opencode Language mode dispatch (extension → CM6 language extension)

Files to modify

File Repo Change
packages/app/package.json opencode Add CM6 dependencies (see list below)
packages/app/src/components/session/preview-content-area.tsx opencode (from slice 2) Replace editor placeholder with <CodeMirrorEditor>
packages/app/src/components/session/session-preview-tab.tsx opencode Replace RawEditor (lines 399-427) with <CodeMirrorEditor> for .md raw mode

Dependencies to add

{
  "codemirror": "^6.0.0",
  "@codemirror/lang-markdown": "^6.0.0",
  "@codemirror/lang-javascript": "^6.0.0",
  "@codemirror/legacy-modes": "^6.0.0",
  "@codemirror/language": "^6.0.0",
  "@codemirror/state": "^6.0.0",
  "@codemirror/view": "^6.0.0",
  "@codemirror/commands": "^6.0.0",
  "@codemirror/search": "^6.0.0"
}

Integration points (exact)

1. SolidJS + CodeMirror mount pattern — follow the @pierre/diffs Web Component pattern at session-ui/src/components/file-ssr.tsx:90-145 (ref + onMount + createEffect + onCleanup):

// codemirror-editor.tsx
import { EditorView, basicSetup } from "codemirror";
import { EditorState, Compartment } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { onMount, onCleanup, createEffect, on } from "solid-js";

export function CodeMirrorEditor(props: {
  content: string;
  extension: string;        // file extension for language mode
  readOnly?: boolean;
  onSave?: (content: string) => void;
  onChange?: (content: string) => void;
}) {
  let containerRef!: HTMLDivElement;
  let view: EditorView | undefined;
  const languageCompartment = new Compartment();
  const readOnlyCompartment = new Compartment();

  onMount(() => {
    view = new EditorView({
      state: EditorState.create({
        doc: props.content,
        extensions: [
          basicSetup,
          languageCompartment.of(languageForExtension(props.extension)),
          readOnlyCompartment.of(EditorState.readOnly.of(props.readOnly ?? false)),
          amicodeTheme,                    // custom theme (see codemirror-theme.ts)
          keymap.of([{
            key: "Mod-s",
            run: () => { props.onSave?.(view!.state.doc.toString()); return true; },
          }]),
          EditorView.updateListener.of((update) => {
            if (update.docChanged) {
              props.onChange?.(update.state.doc.toString());
            }
          }),
        ],
      }),
      parent: containerRef,
    });
  });

  // React to readOnly prop changes without remounting
  createEffect(on(() => props.readOnly, (ro) => {
    view?.dispatch({ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(ro ?? false)) });
  }));

  onCleanup(() => { view?.destroy(); });

  return <div ref={containerRef} class="h-full w-full overflow-hidden" />;
}

2. Theme using --v2-* tokens — the codebase uses --v2-* CSS custom properties (NOT --amc-*). Key tokens from packages/ui/src/theme/themes/harmoniqs.json:

// codemirror-theme.ts
import { EditorView } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";

export const amicodeTheme = EditorView.theme({
  "&": {
    backgroundColor: "var(--v2-background-bg-base)",
    color: "var(--v2-text-text-base)",
    fontFamily: "var(--font-mono)",
  },
  ".cm-gutters": {
    backgroundColor: "var(--v2-background-bg-layer-01)",
    color: "var(--v2-text-text-muted)",
    border: "none",
  },
  ".cm-activeLineGutter": {
    backgroundColor: "var(--v2-background-bg-layer-02)",
  },
  ".cm-activeLine": {
    backgroundColor: "var(--v2-background-bg-layer-02)",
  },
  "&.cm-focused .cm-cursor": {
    borderLeftColor: "var(--v2-text-text-base)",
  },
  "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
    backgroundColor: "var(--v2-background-bg-layer-02)",
  },
  ".cm-line": {
    padding: "0 4px",
  },
});

// Syntax highlighting using the theme's syntax-* tokens:
export const amicodeHighlighting = syntaxHighlighting(HighlightStyle.define([
  { tag: tags.comment, color: "var(--v2-text-text-muted)" },
  { tag: tags.keyword, color: "var(--v2-text-text-accent)" },
  { tag: tags.string, color: "var(--v2-text-text-base)" },    // adjust per theme
  { tag: tags.number, color: "var(--v2-text-text-base)" },
]));

Theme switching — the ThemeProvider at packages/ui/src/theme/context applies --v2-* variables to the root element. Because the CM6 theme references var(--v2-*), it auto-switches with no Compartment reconfiguration needed. The light/dark switch happens via AmicodeThemeBridge (app.tsx:412) calling theme.setColorScheme().

3. Language mode dispatch:

// codemirror-languages.ts
import { markdown } from "@codemirror/lang-markdown";
import { StreamLanguage } from "@codemirror/language";
import { stex } from "@codemirror/legacy-modes/mode/stex";
import type { Extension } from "@codemirror/state";

export function languageForExtension(ext: string): Extension {
  switch (ext.toLowerCase()) {
    case ".md":
      return markdown();
    case ".tex": case ".bib": case ".tikz": case ".sty": case ".cls":
      return StreamLanguage.define(stex);
    case ".txt": case ".log": default:
      return [];  // plain text — no language mode
  }
}

4. Auto-save — replicate the debounce pattern from session-preview-tab.tsx:153-205 but use the SDK typed method:

import { useSDK } from "@/context/sdk";

// Inside the component or a wrapper:
const sdk = useSDK();
let saveTimer: ReturnType<typeof setTimeout> | undefined;
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saving" | "saved">("idle");

function debouncedSave(path: string, content: string) {
  if (saveTimer) clearTimeout(saveTimer);
  saveTimer = setTimeout(() => saveFile(path, content), 1000);
}

async function saveFile(path: string, content: string) {
  setSaveStatus("saving");
  try {
    await sdk().client.file.write({ path, content });
    setSaveStatus("saved");
    setTimeout(() => setSaveStatus("idle"), 2000);
  } catch {
    setSaveStatus("idle");
  }
}

5. Save indicator — use --status-running and --status-done tokens from design-polish.css:105-117:

<Show when={saveStatus() !== "idle"}>
  <span
    class="text-11-medium"
    style={{
      color: saveStatus() === "saving"
        ? "var(--status-running)"    // Harmoniqs yellow
        : "var(--status-done)",      // green
    }}
  >
    {saveStatus() === "saving" ? "Saving…" : "Saved"}
  </span>
</Show>

6. Cmd+S interception — CodeMirror's keymap handles Mod-s natively (see the mount code above). The existing RawEditor at session-preview-tab.tsx:411-416 uses e.stopPropagation() to prevent the app's global command system from stealing the shortcut. CM6's keymap does this automatically when run() returns true.

Bundle size

~150KB gzipped for codemirror + language modes + view/state/commands. Verify with npx vite build.

Verification

# Unit tests (opencode app uses bun test, not vitest)
cd ~/harmoniqs/opencode/packages/app && bun test --conditions=solid --preload ./happydom.ts src/utils/codemirror-languages.test.ts
cd ~/harmoniqs/opencode/packages/app && bun test --conditions=solid --preload ./happydom.ts src/components/codemirror-editor.test.ts

# Test expectations:
# - languageForExtension(".tex") returns StreamLanguage stex
# - languageForExtension(".md") returns markdown()
# - languageForExtension(".txt") returns empty array
# - Auto-save debounce: edit → wait 1s → save fires (mock SDK)
# - Cmd+S: immediate save fires (mock SDK)
# - Read-only mode: edits rejected

# Visual check in Extension Dev Host (F5):
# 1. Open a .tex file → line numbers visible, LaTeX syntax highlighted
# 2. Open a .md file in raw mode → markdown syntax highlighted
# 3. Edit a file → "Saving..." appears in yellow, then "Saved" in green
# 4. Press Cmd+S → immediate save
# 5. Open a .log file → read-only (cursor but no editing)
# 6. Switch light/dark theme → editor theme updates without reload

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

afkImplementable without human interactionarea:uienhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions