Code Editor Pane
Overview
A text editor pane for viewing and editing source code files with syntax highlighting, line numbers, minimap, dirty state tracking, and auto-save. Loads file contents asynchronously and provides language-aware editing via CodeEditSourceEditor on Apple platforms. Derived from scratching-post FileEditorView and EditorState.
Terminology
| Term | Definition |
|---|---|
| EditorState | An ObservableObject that manages the loaded file content, modification tracking, load state, and save operations for a single file |
| Dirty state | The editor has unsaved modifications — determined by comparing current content to last-saved content |
| loadGeneration | A monotonically increasing counter that forces the SourceEditor to be recreated when a new file is loaded, preventing stale editor state |
| Syntax highlighting | Colorized rendering of source code tokens (keywords, strings, comments, etc.) based on the detected language |
| Minimap | A scaled-down overview of the entire file shown as a narrow column on the trailing edge of the editor |
| Gutter | The column to the left of the editing area that displays line numbers |
| Auto-save | Automatic persistence of modified content when the user switches to a different file |
Behavioral Requirements
File loading
- async-file-load: The editor MUST load file contents asynchronously as UTF-8 text. During loading, a progress spinner MUST be displayed.
- set-loaded-state: On successful load, the editor MUST set
isLoadedto true, populatecontentwith the file text, and incrementloadGenerationto force the SourceEditor to recreate. - binary-file-placeholder: If the file cannot be read as UTF-8 text (binary file, encoding error), the editor MUST display a placeholder: "Cannot display this file type".
- no-file-placeholder: If no file is selected, the editor MUST display a placeholder: "Select a file to view its contents".
- directory-placeholder: If a directory is selected (rather than a file), the editor MUST display a directory-appropriate placeholder rather than attempting to load.
- load-generation-identity: The
loadGenerationcounter MUST be incremented each time a new file is loaded. The SourceEditor view MUST use this value as an identity key (e.g., SwiftUI.id(loadGeneration)) so that the editor is fully recreated for each file, preventing stale content or cursor position from the previous file.
Syntax highlighting and language detection
- language-detection: The editor MUST detect the programming language from the file extension and apply syntax highlighting accordingly.
- supported-language-map: Language detection MUST support at minimum the following mappings:
| Extension(s) | Language |
|---|---|
.swift |
Swift |
.json |
JSON |
.md, .markdown |
Markdown |
.py |
Python |
.js |
JavaScript |
.ts |
TypeScript |
.jsx |
JSX |
.tsx |
TSX |
.yaml, .yml |
YAML |
.toml |
TOML |
.html, .htm |
HTML |
.css |
CSS |
.sh, .bash, .zsh |
Shell |
.rb |
Ruby |
.rs |
Rust |
.go |
Go |
.c, .h |
C |
.cpp, .hpp, .cc |
C++ |
.java |
Java |
.kt, .kts |
Kotlin |
.xml, .plist |
XML |
.sql |
SQL |
.r, .R |
R |
.lua |
Lua |
.dockerfile, Dockerfile |
Dockerfile |
.gitignore |
Git Ignore |
- plain-text-fallback: If the file extension is unrecognized, the editor MUST fall back to plain text (no syntax highlighting).
- system-appearance-theme: The editor MUST follow the system appearance to select a dark or light theme. On Apple platforms, use CatnipDark for dark mode and CatnipLight for light mode, or equivalent named themes from the syntax highlighting library.
Editor configuration
- gutter-enabled: The gutter (line numbers) MUST be enabled by default.
- minimap-enabled: The minimap MUST be enabled by default.
- no-line-wrap: Line wrapping MUST be disabled. Horizontal scrolling MUST be used for long lines.
- default-monospaced-font: The default font MUST be Menlo 13pt (monospaced). The font MAY be configurable via project or app settings.
Dirty state and saving
- dirty-state-tracking: The editor MUST track dirty state by subscribing to content changes (via Combine or equivalent reactive mechanism) and comparing the current content to the last-saved content.
- is-modified-flag: When the content differs from the last-saved content,
isModifiedMUST be set to true. When they match,isModifiedMUST be set to false. - auto-save-on-switch: When the user switches to a different file and
isModifiedis true, the editor MUST auto-save the current file before loading the new file. - manual-save-shortcut: The user MUST be able to trigger a manual save via Cmd+S (macOS) or the platform-equivalent keyboard shortcut.
- atomic-save: Save MUST write the content atomically to disk to prevent data loss from partial writes.
- reset-modified-after-save: After a successful save,
isModifiedMUST be reset to false and the last-saved content snapshot MUST be updated.
EditorState
- editor-state-properties: EditorState MUST be implemented as an ObservableObject (or platform equivalent) with the following published properties:
content: String— the current text in the editorisModified: Bool— whether the content has unsaved changesloadError: String?— an error message if the file could not be loadedisLoaded: Bool— whether the file has been successfully loadedloadGeneration: Int— incremented to force editor recreation on file change
- debounce-dirty-check: EditorState MUST debounce dirty-state comparison by a short interval (e.g., 0.3s) to avoid excessive comparisons during rapid typing.
Pane header
- collapsible-header: The editor pane MUST use the collapsible-pane-header component at the top.
- header-shows-filename: When a file is selected, the header MUST display a file icon and the filename.
- header-generic-title: When no file is selected, the header MUST display a generic title (e.g., "Editor").
- dirty-indicator-in-header: When the file is modified (dirty), the header SHOULD display a dirty indicator (e.g., a dot or bullet adjacent to the filename, or the standard macOS edited-document indicator).
Appearance
Editor pane layout
┌────────────────────────────────────────────────────────┐
│ ▼ 📄 ContentView.swift ● │ ← pane header (collapsible)
├──────────────────────────────────────────────────┬─────┤
│ 1 import SwiftUI │▓▓▓▓▓│
│ 2 │▓░░▓▓│
│ 3 struct ContentView: View { │▓░░▓▓│
│ 4 var body: some View { │▓░░▓▓│
│ 5 VStack { │▓░░▓▓│
│ 6 Image(systemName: "globe") │▓░░▓▓│
│ 7 .imageScale(.large) │▓▓▓▓▓│
│ 8 .foregroundStyle(.tint) │▓▓▓▓▓│
│ 9 Text("Hello, world!") │▓▓▓▓▓│
│10 } │▓▓▓▓▓│
│11 .padding() │▓▓▓▓▓│
│12 } │ │
│13 } │ │
│14 │ │
│ │ │
└──────────────────────────────────────────────────┴─────┘
↑ gutter (line numbers) ↑ editor area ↑ minimap
No file selected (empty state)
┌────────────────────────────────────────────────────────┐
│ ▼ Editor │
├────────────────────────────────────────────────────────┤
│ │
│ │
│ 📄 │
│ Select a file to view its contents │
│ │
│ │
└────────────────────────────────────────────────────────┘
Binary file placeholder
┌────────────────────────────────────────────────────────┐
│ ▼ 📄 image.png │
├────────────────────────────────────────────────────────┤
│ │
│ │
│ ⚠️ │
│ Cannot display this file type │
│ │
│ │
└────────────────────────────────────────────────────────┘
- Gutter: Monospaced, right-aligned line numbers, secondary text color, subtle separator from editor area
- Editor area: Monospaced font (Menlo 13pt default), themed background per color-profile
- Minimap: ~60pt wide trailing column, scaled-down representation of the file
- Dirty indicator: Small filled circle (●) adjacent to the filename in the pane header, or platform-standard edited-document indicator
States
| State | Behavior |
|---|---|
| No file selected | Empty state placeholder: "Select a file to view its contents" |
| Loading file | ProgressView spinner centered in the editor area |
| File loaded | Editor displayed with syntax highlighting, line numbers, minimap |
| Binary / non-UTF-8 file | Placeholder: "Cannot display this file type" |
| Directory selected | Placeholder appropriate for directory (e.g., "Select a file to view its contents") |
| Modified (dirty) | Dirty indicator shown in pane header; isModified is true |
| Unmodified (clean) | No dirty indicator; isModified is false |
| Save in progress | Content written atomically; on completion, dirty state cleared |
| Load error | Error placeholder displayed with the load error message |
| Pane collapsed | Header visible (via collapsible-pane-header), editor content hidden |
Accessibility
- text-editor-a11y-role: The editor MUST be accessible as a text editor role to screen readers and MUST support standard text navigation (by character, word, line).
- header-a11y-compliance: The pane header MUST follow collapsible-pane-header accessibility requirements (button role, expand/collapse announced).
- dirty-state-a11y: The dirty indicator MUST be communicated to assistive technologies — e.g., the header's accessibility label SHOULD include "edited" or "modified" when
isModifiedis true. - placeholder-a11y: Empty state and error placeholders MUST follow empty-state accessibility requirements (heading announced first, icon decorative).
- save-menu-discoverable: The Cmd+S save shortcut MUST be discoverable via the app's menu bar (File > Save) on macOS.
- decorative-line-numbers: Line numbers in the gutter MUST be decorative and not announced individually by screen readers.
Conformance Test Vectors
| ID | Requirements | Input | Expected |
|---|---|---|---|
| ced-001 | async-file-load | Select a .swift file | Loading spinner shown, then editor with syntax-highlighted Swift content |
| ced-002 | set-loaded-state | Load a file successfully | isLoaded is true, content matches file text, loadGeneration incremented |
| ced-003 | binary-file-placeholder | Select a .png file | Placeholder "Cannot display this file type" displayed |
| ced-004 | no-file-placeholder | No file selected | Placeholder "Select a file to view its contents" displayed |
| ced-005 | directory-placeholder | Select a directory node | Directory placeholder displayed, no file load attempted |
| ced-006 | load-generation-identity | Load file A, then load file B | loadGeneration incremented for each load; editor recreated (no stale state from A) |
| ced-007 | language-detection, supported-language-map | Load file.swift | Language detected as Swift, syntax highlighting applied |
| ced-008 | supported-language-map | Load file.py | Language detected as Python |
| ced-009 | supported-language-map | Load file.yaml | Language detected as YAML |
| ced-010 | plain-text-fallback | Load file.xyz (unknown extension) | Plain text mode, no syntax highlighting |
| ced-011 | system-appearance-theme | System in dark mode | CatnipDark theme applied to editor |
| ced-012 | system-appearance-theme | System in light mode | CatnipLight theme applied to editor |
| ced-013 | system-appearance-theme | Toggle system appearance while editor is open | Theme switches without reloading file |
| ced-014 | gutter-enabled | Load any file | Line numbers visible in gutter |
| ced-015 | minimap-enabled | Load any file | Minimap visible on trailing edge |
| ced-016 | no-line-wrap | Load file with 500-character line | No wrapping; horizontal scroll available |
| ced-017 | default-monospaced-font | Load any file | Font is Menlo 13pt monospaced |
| ced-018 | dirty-state-tracking, is-modified-flag | Type a character in the editor | isModified becomes true |
| ced-019 | is-modified-flag | Undo all changes back to saved state | isModified becomes false |
| ced-020 | auto-save-on-switch | Edit file A, select file B | File A auto-saved before file B loads |
| ced-021 | manual-save-shortcut | Press Cmd+S with unsaved changes | File saved, isModified becomes false |
| ced-022 | atomic-save | Save file | File written atomically (no partial content on disk) |
| ced-023 | reset-modified-after-save | Save file, then check state | isModified is false, last-saved snapshot updated |
| ced-024 | debounce-dirty-check | Type rapidly (10 chars in 0.2s) | Dirty comparison fires once after debounce, not per keystroke |
| ced-025 | collapsible-header | View editor pane | Collapsible pane header present at top |
| ced-026 | header-shows-filename | Select ContentView.swift | Header shows file icon + "ContentView.swift" |
| ced-027 | header-generic-title | No file selected | Header shows "Editor" |
| ced-028 | dirty-indicator-in-header | Edit file (make dirty) | Dirty indicator (●) appears in header |
| ced-029 | dirty-indicator-in-header | Save file (clear dirty) | Dirty indicator removed from header |
| ced-030 | dirty-state-a11y | VoiceOver active, file is dirty | Header announces "ContentView.swift, edited" |
| ced-031 | save-menu-discoverable | Open menu bar File menu | "Save" item present with Cmd+S shortcut |
Edge Cases
- Very large files (1MB+): The editor SHOULD load and render without blocking the main thread. If the file exceeds a configurable size threshold (e.g., 5MB), the editor MAY display a warning or truncate rendering. The editor MUST NOT crash.
- Binary files: Files that cannot be decoded as UTF-8 MUST show the "Cannot display this file type" placeholder. The editor MUST NOT attempt to render binary data as text.
- File deleted while editing: If the file is deleted externally while open in the editor, the editor SHOULD detect this on the next save attempt and present an appropriate error (e.g., "File no longer exists. Save as...?" or re-create the file). The editor MUST NOT crash or silently lose content.
- File modified externally (concurrent edit): If the file is modified by another process while open, the editor SHOULD detect the external change (e.g., via file system events or mtime check on save) and warn the user before overwriting. The editor MUST NOT silently discard external changes without notice.
- Encoding issues: Files with mixed encoding, BOM markers, or invalid UTF-8 sequences MUST be handled gracefully. Invalid bytes SHOULD cause the file to be treated as non-displayable (binary-file-placeholder), not crash the editor.
- Empty file: A zero-byte file MUST load successfully and display an empty editor (not a placeholder). The file SHOULD be editable.
- Read-only file: If the file does not have write permissions, the editor SHOULD indicate read-only status. Save attempts MUST show an error rather than silently failing.
- File path with special characters: Paths containing spaces, unicode characters, or shell-special characters MUST be handled correctly for both load and save operations.
- Rapid file switching: If the user switches files faster than the async load completes, only the most recently selected file's content MUST be displayed. Stale load results MUST be discarded (the
loadGenerationmechanism handles this). - Save fails (disk full, permissions): Save errors MUST be surfaced to the user (e.g., via an alert or inline error) and the dirty state MUST remain true so the user does not lose their changes.
- Undo after save: Undo history is per-editor-session. After save, undo SHOULD still work to revert to pre-save content (the dirty indicator reappears if content diverges from the saved snapshot).
- New file with no extension: Files without an extension MUST load as plain text with no syntax highlighting.
- Extremely long lines (10,000+ characters): The editor MUST remain responsive. Horizontal scrolling (no-line-wrap) handles display. The minimap SHOULD still render without performance degradation.
- Tab characters vs spaces: The editor MUST preserve the original indentation characters in the file. Tab width rendering MAY be configurable (default: 4 spaces).
Configuration
This ingredient has no configurable options.
Logging
Subsystem: {{bundle_id}} | Category: CodeEditorPane
| Event | Level | Message |
|---|---|---|
| File load started | debug | CodeEditorPane: loading file "{{path}}" |
| File load succeeded | debug | CodeEditorPane: loaded "{{filename}}" ({{bytes}} bytes, language: {{language}}) |
| File load failed (encoding) | debug | CodeEditorPane: cannot display "{{filename}}" — not valid UTF-8 |
| File load failed (error) | error | CodeEditorPane: failed to load "{{path}}": {{error}} |
| Language detected | debug | CodeEditorPane: detected language "{{language}}" for extension "{{ext}}" |
| Theme applied | debug | CodeEditorPane: applied theme "{{theme}}" (appearance: {{light|dark}}) |
| Content modified | debug | CodeEditorPane: "{{filename}}" marked as modified |
| Content reverted to clean | debug | CodeEditorPane: "{{filename}}" marked as clean (matches saved) |
| Auto-save triggered | debug | CodeEditorPane: auto-saving "{{filename}}" before switching to "{{newFilename}}" |
| Manual save triggered | debug | CodeEditorPane: manual save "{{filename}}" (Cmd+S) |
| Save succeeded | debug | CodeEditorPane: saved "{{filename}}" ({{bytes}} bytes) |
| Save failed | error | CodeEditorPane: save failed for "{{path}}": {{error}} |
| External modification detected | warning | CodeEditorPane: "{{filename}}" modified externally |
| File deleted while open | warning | CodeEditorPane: "{{filename}}" deleted externally while open in editor |
| Load generation incremented | debug | CodeEditorPane: loadGeneration incremented to {{generation}} for "{{filename}}" |
| Placeholder displayed | debug | CodeEditorPane: showing placeholder — {{reason}} |
| Editor recreated | debug | CodeEditorPane: editor recreated (loadGeneration={{generation}}) |
| Large file warning | warning | CodeEditorPane: "{{filename}}" is {{size}}MB — may impact performance |
Accessibility Options
| Option | Behavior |
|---|---|
| Reduce Motion | Pane collapse/expand transitions are instant (per collapsible-pane-header) |
| Increase Contrast | Editor uses higher-contrast syntax theme variant; gutter separator more prominent |
| Differentiate Without Color | Syntax highlighting uses bold/italic/underline styles in addition to color to differentiate token types |
| VoiceOver | Editor text navigable by character/word/line; header announces filename, modified state, and collapse state; placeholders announced per empty-state requirements |
| Dynamic Type | Editor font size SHOULD respect the system font size preference while maintaining monospaced rendering |
Platform Notes
- SwiftUI (macOS): Use
CodeEditSourceEditor(from the CodeEditSourceEditor package) as the primary editor component, wrapped in anNSViewRepresentableif needed. Set language viaCodeLanguageenum mapped from file extension. Configure:lineNumbers: true,minimap: true,wrapLines: false, font:NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)orNSFont(name: "Menlo", size: 13). Theme: useEditorThemeconforming types — CatnipDark and CatnipLight — switching based on@Environment(\.colorScheme). Bind editor text toEditorState.contentas aBinding<String>. Use.id(editorState.loadGeneration)on the editor view to force recreation when a new file loads. Dirty state tracking: subscribe toeditorState.$contentvia Combine, debounce 0.3s, compare tolastSavedContentsnapshot. Save: useData(content.utf8).write(to: fileURL, options: .atomic)orString.write(to:atomically:encoding:). Auto-save: in the file-selectiononChangehandler, callsave()ifisModifiedbefore loading the new file. Cmd+S: register via.keyboardShortcut("s", modifiers: .command)on a hidden button or via thecommandsmodifier on the scene. Pane header: use collapsible-pane-header with the filename as title and a file-type SF Symbol as the icon. - SwiftUI (iOS / visionOS): CodeEditSourceEditor may not be available on iOS. Use a
UITextView-based editor with custom syntax highlighting (e.g., Highlightr or a tree-sitter wrapper) inside aUIViewRepresentable. Line numbers and minimap may need custom drawing. On visionOS, the editor pane appears within the workspace window's detail area. Keyboard shortcut Cmd+S is available when an external keyboard is connected. - Compose (Android): Use a code editor library such as CodeView or Sora Editor. Configure syntax highlighting via language grammars. Line numbers and minimap depend on library capabilities. Dirty state tracking via
MutableState<String>observation. Save withFile.writeText()usingcreateTempFile+renameTofor atomic writes. Auto-save triggered inonDisposeor selection-change callback. - Web (React): Use Monaco Editor or CodeMirror 6. Monaco provides built-in language support, minimap, line numbers, and theme switching. Bind editor value to React state. Dirty tracking via
onChangecallback comparing to saved snapshot. Save via backend API or File System Access API. Cmd+S / Ctrl+S intercepted viaeditor.addCommandoronKeyDownhandler. Theme: configurevs-dark/vsbased onprefers-color-schememedia query.
Privacy
- Data collected: File contents are loaded into memory for editing. No content is transmitted off-device.
- Storage: File contents are persisted only to their original file path on save. No copies or caches are created.
- Transmission: None — file content never leaves the device.
- Retention: Editor content exists in memory only for the lifetime of the editing session. Closing the file releases memory.
Design Decisions
None yet — decisions made during implementation should be recorded here.