Skip to content

Built-in Editor

The right sidebar's Editor tab opens any text file in the active project's working directory in a CodeMirror 6 buffer, with explicit save and optimistic concurrency against the on-disk file. This is milestone 3 of the right-sidebar feature.

Image, audio, and video files in the same tree open in a read-only media viewer in the same pane - see Media viewer below.

Where files come from

  • Click any file in the Files tab → opens in the editor and switches to the Editor tab.
  • Click the file-edit icon in the Git diff viewer's header → same.
  • Both routes flow through the global appBus.emit('open-in-editor', …) event so any future trigger (command palette, keyboard shortcut, etc.) hooks into the same path without prop-drilling.

The editor holds a single open buffer at a time. The narrow sidebar column makes a tab strip impractical; switching files prompts the user when the current buffer is dirty.

Editing model

  • The buffer's baseline is what was last loaded or saved; current is what the user is typing. Dirty state is current !== baseline.
  • A small amber dot in the header signals unsaved changes.
  • Save with Ctrl/Cmd+S or the toolbar icon. Save is fire-and-forget on the wire - the daemon answers with project_file_saved (success) or project_file_write_conflict (sha256 mismatch).
  • Revert discards local edits and re-loads from disk.
  • Close drops the buffer; with unsaved changes the user is prompted.

There is no autosave. Read-only buffers are not implemented yet - if the file is read-only on disk, the daemon's write fails with a clear error.

Languages and theming

Only the language packs already present in package.json are wired:

  • @codemirror/lang-css for .css, .scss, .less
  • Everything else opens as plain text (no syntax highlighting).

Adding more languages is a one-line change per pack - see the languageFor helper in CodeEditor.vue. Keeping the list small avoids pulling new dependencies until it's clear which languages users actually edit.

The editor now reads its palette from the app's CSS custom properties, the same way the rest of the UI and the terminal do:

  • Surface variables: --editor-bg, --editor-foreground, --editor-gutter-*, --editor-border, --editor-active-line-*, --editor-selection-bg, --editor-cursor
  • Search / bracket variables: --editor-search-match-*, --editor-bracket-match-*

Because the editor theme is CSS-variable driven, theme toggles and custom CSS edits update already-open editors live without recreating them.

Wire protocol

WriteProjectFile

jsonc
// client → daemon
{
  "type": "write_project_file",
  "project_id": "<uuid>",
  "path": "src/lib.rs",
  "content": "fn main() {}\n",
  "base_sha256": "abc123…"   // omit / null to force-write a new file
}

The daemon validates the path (canonicalize the parent directory and ensure it stays inside the project's working_dir), enforces a 1 MiB write cap, performs an optimistic concurrency check when base_sha256 is provided, and writes atomically: bytes go to a <name>.<uuid>.tmp file in the same directory, the file's existing mode is preserved on Unix, then a rename swaps it into place. The temp file is removed on rename failure.

ProjectFileSaved

jsonc
{
  "type": "project_file_saved",
  "project_id": "<uuid>",
  "path": "src/lib.rs",
  "sha256": "def456…"
}

The new sha256 is the digest the editor should treat as its base for the next save.

ProjectFileWriteConflict

jsonc
{
  "type": "project_file_write_conflict",
  "project_id": "<uuid>",
  "path": "src/lib.rs",
  "current_sha256": "ff00ff00…"
}

Returned when base_sha256 was provided and the on-disk file's current digest differs (or the file no longer exists). The editor shows an inline banner with Reload (re-baseline against disk, lose edits) and Overwrite (force-write, discarding the other side's changes). Genuine errors (path traversal, oversized payload, parent missing) come back as project_file_error instead.

Run-as-user

Writes happen as the daemon's own user, like reads in M1. The path containment check is the security boundary. If your project's run_as_users map points at a different OS user, the file ends up owned by the daemon user; per-user write impersonation can be added later if needed.

Frontend integration

  • Pinia stores:
    • src/stores/files.ts - owns content cache, loadContent, saveContent, plus the new onFileSaved / onFileWriteConflict event handlers.
    • src/stores/editor.ts - owns the single open buffer, dirty state, save / revert / close, and the surfaced conflict notice.
  • Components:
    • src/components/sidebar-right/CodeEditor.vue - CodeMirror host, toolbar, conflict banner.
  • Tauri command: src-tauri/src/commands/files.rs::write_project_file
  • Events: hive:project-file-saved, hive:project-file-write-conflict (registered in src/composables/useTauriEvents.ts)

Limitations

  • No autosave; explicit Ctrl/Cmd+S.
  • No multi-buffer / tab strip; one open file at a time.
  • No diff-against-saved view (M2's DiffView covers worktree vs index).
  • Language pack list is intentionally short - extend as needed.
  • 1 MiB write cap, matching the read cap, so the editor's round-trip can't silently grow past what it can load back.

Media viewer

Image, audio, and video files use the same open-file flow as text - clicking one in the Files tab opens it in the editor pane - but render in a read-only viewer instead of CodeMirror.

  • Detection. By file extension on the frontend (src/lib/mediaKind.ts). Image: png, jpg/jpeg, gif, webp, avif, bmp, ico, svg. Audio: mp3, wav, ogg/oga, flac, m4a, aac, opus. Video: mp4/m4v, webm, mov, mkv, ogv.
  • Rendering. <img>, <audio controls>, or <video controls> whose src is a data:<mime>;base64,<bytes> URL built from the daemon's response.
  • Toolbar. Save and Revert are hidden; only Close is available.
  • Size cap. 25 MiB per file (vs 1 MiB for text). Bytes are base64- encoded into a single WebSocket frame, so larger files are rejected with project_file_error.

ReadProjectFileBinary

jsonc
// client → daemon
{
  "type": "read_project_file_binary",
  "project_id": "<uuid>",
  "path": "assets/logo.png"
}

The daemon validates the path (same containment check as the text reader), enforces the 25 MiB cap, reads the file, derives a MIME type from the extension, and responds with:

ProjectFileBinary

jsonc
{
  "type": "project_file_binary",
  "project_id": "<uuid>",
  "path": "assets/logo.png",
  "data": "<base64>",     // raw bytes, base64-encoded by serde
  "mime": "image/png",
  "sha256": "abc123…"
}

Failures (path traversal, oversize, missing file) come back as the same project_file_error variant the text reader uses. There is no write counterpart - media files are read-only in the viewer.

Frontend integration (additions)

  • src/lib/mediaKind.ts - extension → kind detection.
  • src/stores/files.ts - adds loadBinaryContent and the onFileBinaryContent event handler alongside the text equivalents.
  • src/stores/editor.ts - OpenBuffer is a discriminated union over text and media; openFile picks the transport by extension.
  • src/components/workspace/CodeEditor.vue - renders <img> / <audio> / <video> for media buffers; CodeMirror remains for text.
  • Tauri command: src-tauri/src/commands/files.rs::read_project_file_binary
  • Event: hive:project-file-binary (registered in src/composables/useTauriEvents.ts).

Hive - remote AI coding agents over WebSocket.