docs/internal/design-decisions.md
This document preserves the key design decisions, trade-offs, and rationale from Fresh's development history. It serves as an audit trail so future contributors can understand why things are the way they are without needing to rediscover the reasoning.
The original per-feature design documents for shipped features have been removed — this file is now the canonical record. In-progress designs remain in their own files in this directory.
Scope: Covers decisions that have been implemented and shipped. In-progress designs remain in their own files.
Problem: Tests using real wall-clock time are slow and non-deterministic.
Decision: Introduce a TimeSource trait (src/services/time_source.rs)
with RealTimeSource for production and TestTimeSource for tests.
TestTimeSource advances logical time rather than sleeping.
Trade-offs considered:
crossterm::event::poll, signal
handler thread::sleep)Key principle: Services receive SharedTimeSource through composition.
Future time-based code should use this abstraction.
Previously: timesource-design.md
Problem: Multi-cursor edits via sequential Event::Batch had O(n²)
complexity — each event triggered a full tree traversal.
Decision: Introduce Event::BulkEdit that applies all edits in a single
tree traversal. Use Arc clone of the tree snapshot for O(1) undo instead of
storing individual events.
Impact: ~500× improvement for multi-cursor operations. All multi-cursor,
replace-all, toggle-comment, indent, LSP rename, and multi-cursor paste now
use BulkEdit.
Key principle: Converting N sequential operations into 1 structural operation. Arc snapshots are cheap — exploit that for undo.
Previously: bulk-edit-optimization.md
Problem: Flat flag structure didn't scale as Fresh gained session management, remote editing, and file-opening features.
Decision: Move to git/cargo-style subcommands (fresh session attach,
fresh session list) with backward-compatible shortcuts (fresh -a).
Deprecated flags produce warnings rather than breaking.
Trade-offs considered:
fresh file.txt (simple) vs
fresh session attach --name dev (explicit)Previously: cli-redesign.md
Problem: Needed a universal entry point for file finding, buffer switching, and command execution.
Decision: Unified Ctrl+P with prefix-based mode switching (VSCode model):
no prefix = files, > = commands, # = buffers, : = go-to-line.
Research: Comparative analysis of VSCode, Sublime, Neovim (Telescope), JetBrains, and Emacs. Key takeaways:
File discovery hierarchy: git ls-files → fd → find → manual traversal
(best performance where available, respects .gitignore).
Implementation: plugins/find_file.ts with the Finder<T> abstraction
planned for further deduplication (see finder-abstraction.md for the
in-progress design that targets 87% code reduction across 5 finder plugins).
Previously: FUZZY_FILE_FINDER_UX.md
Problem: Support non-UTF-8 files (Latin-1, Shift-JIS, GBK, etc.) without breaking the UTF-8-based editing pipeline.
Decision: Normalize on Load — convert to UTF-8 immediately, track original encoding, convert back on save. Mirrors the CR/LF architecture (detect → track → convert back).
Alternatives rejected:
Open questions preserved: Invalid byte handling strategy, mixed-encoding detection, chunk boundary alignment for multi-byte encodings.
Previously: encoding-support-design.md
Problem: Side-by-side diff needs aligned rendering of non-consecutive lines, and scroll sync between panes causes feedback loops when done via async plugin hooks.
Diff view decision: Introduce CompositeBuffer with ChunkAlignment
markers at hunk boundaries. Markers are O(chunks) not O(lines) and
auto-adjust when edits occur. Separate rendering path for composite buffers
(aligned with gaps) vs normal buffers (consecutive lines).
Scroll sync decision: Use marker-based sync anchors instead of async
plugin hooks. Single source of truth: scroll_line in the left buffer's line
space, derived positions for the right pane. Synchronous sync at render time
eliminates race conditions and jitter. Leverages existing MarkerList /
IntervalTree infrastructure.
See also: diff-view.md, scroll-sync-design.md (partially implemented)
Problem: External paste (Cmd+V in terminal) produces a burst of key events indistinguishable from fast typing, causing unwanted auto-close/auto-indent.
Decision: Two-tier approach:
Both paths produce a single "atomic insert" for consistent undo behavior. Auto-close and skip-over are suppressed during paste.
Previously: paste-handling.md
Problem: Terminal editors lose state when the terminal disconnects. Need detach/reattach like tmux but integrated into the editor.
Decision: Dual-socket client/server architecture:
Ultra-light client principle: Client is ~80-100 lines, a "dumb pipe." All complexity lives server-side for easier testing and fault isolation.
Alternatives rejected (with detailed trade-off matrix):
IPC: Unix sockets (Linux/macOS), named pipes (Windows) via interprocess
crate.
Known limitations (documented for future work): single client at a time, no crash resurrection, no multi-client broadcast.
Previously: session-persistence-design.md
Problem: All UI strings were hardcoded in English.
Decision: Use rust-i18n crate with compile-time embedding via
include_str!. JSON locale files, zero runtime overhead for the default
locale.
Alternatives rejected:
.po toolchain, FFI dependencyMigration strategy: 6-phase approach prioritized by visibility: status bar → menus → dialogs → errors → internal. ~170 strings categorized across 10 UI components.
Previously: i18n-design.md
Problem: When the same buffer is open in multiple splits, cursor positions and view state were shared, causing confusing synchronized scrolling.
Decision: BufferViewState keyed by BufferId, stored per-split. Content
is shared (one EditorState), view state is independent (one
BufferViewState per split per buffer).
Plugin state: HashMap<String, serde_json::Value> allows plugins to store
arbitrary per-buffer-per-split state without Rust-side enum changes. Write-
through cache (EditorStateSnapshot) enables immediate read-back within the
same hook execution.
Workspace persistence: file_states: HashMap<PathBuf, SerializedFileState>
stores per-file state that survives session restarts.
Previously: per-buffer-view-state-design.md
Problem: EditorState had 18 fields with mixed concerns (decorations,
highlighting, mode flags), making it hard to reason about.
Decision: Extract into coherent sub-structs:
DecorationState (6 fields): visual annotations sharing marker-list substrateHighlightState (6 fields): all derived from buffer languageBufferFlags (3 fields, optional): user capability controlsExecution order chosen to maximize value-per-churn: DecorationState
first (clearest grouping, ~40 touch points), then HighlightState (~25),
skip BufferFlags (only 3 fields, marginal benefit).
Status: ComposeState extracted as proof-of-concept. Remaining extractions
identified but deferred.
See also: editor-state-refactoring.md (remaining extractions pending)
Problem: Single config file doesn't support project-specific settings, platform overrides, or volatile session state.
Decision: 4-level overlay hierarchy:
System (hardcoded defaults) → User (~/.config/fresh/config.json) →
Project (.fresh/config.json) → Session (volatile, in-memory)
Merge strategy:
languages.python.tab_size)Delta serialization: Only save differences from the parent layer. Setting a value equal to the inherited value prunes the key, preventing config drift.
Conditional layers: Platform-specific (config_linux.json) and
language-specific overrides injected dynamically.
Previously: config-design.md, config-implementation-plan.md
Plugins run in a sandboxed QuickJS JavaScript runtime on a dedicated thread, separate from the main editor thread. Communication is fully asynchronous and non-blocking:
Main thread Plugin thread (QuickJS)
─────────── ──────────────────────
run_hook(name, args) ──────► Hook handlers execute
│
▼
PluginCommand sent back
│
◄───────────────────────────────
process_commands() drains
commands in next frame
Key implementation details (from manager.rs, hooks.rs, api.rs):
PluginManager::run_hook() is fire-and-forget: it serializes
HookArgs to JSON and sends to the plugin thread via channel. The main
thread never waits for hook completion.PluginCommand variants back through a channel.PluginCommands once per frame in
Editor::process_async_messages().Plugins obtain the editor API via getEditor() (returns an EditorAPI
instance scoped to the calling plugin) and register handlers via
registerHandler(name, fn) which replaces the older globalThis pattern.
Handler functions registered this way can be referenced by name in
editor.registerCommand(), editor.on(), and mode keybindings.
Hooks are the editor's way of notifying plugins about state changes. Plugins
subscribe with editor.on(eventName, handlerName). The full set of hooks
(from crates/fresh-core/src/hooks.rs):
File lifecycle: before_file_open, after_file_open, before_file_save,
after_file_save, buffer_closed
Text mutations: before_insert, after_insert, before_delete,
after_delete — include byte positions, line numbers, affected ranges, and
(for after-hooks) line counts added/removed
Cursor & focus: cursor_moved (with line number and text properties at
new position), buffer_activated, buffer_deactivated
Rendering: render_start (once per buffer per frame), render_line
(per visible line), lines_changed (batched line updates),
view_transform_request (provides base tokens for plugin-driven rendering
like markdown compose mode)
UI interaction: prompt_changed, prompt_confirmed, prompt_cancelled,
prompt_selection_changed, mouse_click, mouse_move, mouse_scroll
LSP events: diagnostics_updated, lsp_references,
lsp_server_request, lsp_server_error, lsp_status_clicked
Editor lifecycle: editor_initialized, idle, resize,
viewport_changed, language_changed, pre_command, post_command
Process management: process_output (streaming from background processes),
action_popup_result
When plugins call API methods like editor.insertText() or
editor.addOverlay(), the QuickJS runtime translates these into
PluginCommand enum variants sent back to the main thread. Key command
categories:
InsertText, DeleteRange, InsertAtCursorAddOverlay, ClearNamespace,
ClearOverlaysInRange, AddVirtualText, AddVirtualLine,
SubmitViewTransform, ClearViewTransformAddConceal, ClearConcealNamespace,
AddSoftBreak, SetLayoutHints, SetViewMode, SetLineWrapSetStatus, RegisterCommand, UnregisterCommand,
ShowActionPopup, StartPrompt, SetPromptSuggestionsSpawnProcess, SpawnBackgroundProcess,
KillBackgroundProcess, DelaySetViewState (per-buffer-per-split plugin state,
persisted across sessions)DisableLspForLanguage, RestartLspForLanguage, SetLspRootUri,
SendLspRequestAsync commands (process spawning, getBufferText, delay, prompt,
sendLspRequest) use a JsCallbackId that the main thread resolves or
rejects when the operation completes. The plugin thread handles
resolve_callback/reject_callback to resume the suspended JS promise.
Problem: Plugins that "own the UI" (Controller pattern via virtual buffers) must reimplement navigation, selection, and keybindings, leading to inconsistent UX.
Decision: Standardize on the Provider pattern — plugins provide data, the editor handles UI rendering.
Two-tier API:
QuickPick: transient searches (Live Grep, Git Grep) — plugin provides
results, editor renders the picker with standard navigationResultsPanel: persistent panels (Find References, Diagnostics) with
bidirectional cursor sync via syncWithEditorFor operator+motion combinations (like dw in vi mode), two approaches exist:
delete_word_right
executed synchronously in the core — avoids async timing issuesThe executeActions() batch API with count support enables efficient 3dw
patterns without round-trips.
The most sophisticated plugin-editor interaction. Used by markdown compose mode and other content-transforming plugins:
view_transform_request hook with base tokens for the
visible viewportsubmitViewTransform() with modified token streamKnown timing issue: Because hooks are async, the transformed tokens arrive one frame late. During rapid scrolling or typing, this causes brief flicker where stale/raw content is visible before the plugin's transform arrives. Mitigation strategies identified: hold previous frame's content during scroll, use atomic conceal swaps for single-character edits.
setViewState(bufferId, key, value) / getViewState(bufferId, key) provides
per-buffer-per-split state stored as HashMap<String, serde_json::Value>.
Write-through cache: EditorStateSnapshot (shared via Arc<RwLock>)
enables immediate read-back within the same hook execution — the plugin
doesn't have to wait a frame to read state it just wrote. State persists
across sessions via workspace serialization.
These patterns were learned the hard way across the theme editor, markdown compose, git blame, and git gutter plugins.
Because hooks fire asynchronously, any plugin response (overlay updates,
conceal changes, refreshLines()) arrives at least one frame late. This is
the root cause of most visual glitches.
Mitigation strategies proven in production:
Proactive refreshLines() in Rust (mod.rs:2887–2899): For
inter-line cursor movement, the editor calls handle_refresh_lines()
synchronously before the async cursor_moved hook fires. This means
cursor-dependent conceals (e.g. table row auto-expose in compose mode)
update in the same frame as the cursor move, eliminating the round-trip
lag. Intra-line moves skip this (the plugin's async refreshLines() is
fast enough for span-level changes).
Atomic clear+rebuild batching (markdown_compose.ts:832–838):
clearConcealsInRange() and clearOverlaysInRange() are called
immediately before adding new conceals/overlays for the same range.
Because all commands in a single hook execution are processed in one
process_commands() batch, the clear and rebuild are atomic from the
render loop's perspective — no frame shows the cleared-but-not-rebuilt
state.
Avoid view_transform_request when possible: The markdown compose
plugin originally used view transforms for soft wrapping, causing
one-frame flicker on every keystroke. It was rewritten to use
marker-based soft breaks (setLayoutHints) computed in lines_changed,
eliminating the async round-trip entirely
(markdown_compose.ts:1455–1458). Git blame similarly avoids view
transforms by using addVirtualLine — persistent state the render loop
reads synchronously.
Namespace separation for static vs. dynamic overlays (theme editor):
Use separate namespaces (e.g. "theme" for static content, "theme-sel"
for selection highlights) so that frequent dynamic updates only clear and
rebuild the dynamic namespace. Static overlays survive untouched,
reducing both command volume and visual flicker.
When a plugin programmatically updates buffer content or cursor position, it
triggers the same hooks (e.g. cursor_moved) that the plugin itself
handles. Without a guard, this causes infinite recursion or wasted work.
Pattern (theme editor, theme_editor.ts:1287–1308):
let isUpdatingDisplay = false;
function updateDisplay() {
isUpdatingDisplay = true;
// ... rebuild content, clear/add overlays ...
isUpdatingDisplay = false;
}
function onCursorMoved(data) {
if (isUpdatingDisplay) return; // skip programmatic moves
// ... handle user-initiated cursor moves ...
}
This is simpler and more reliable than debouncing — it prevents re-entrance during the exact window where programmatic updates happen.
When replacing virtual buffer content, clear position-dependent overlays
before the content replace, not after. After setVirtualBufferContent(),
byte offsets change and stale overlay positions point to wrong locations.
Pattern (theme editor, theme_editor.ts:1300–1307):
editor.clearNamespace(bufferId, "theme-sel"); // clear old overlays
editor.setVirtualBufferContent(bufferId, entries); // replace content
applySelectionHighlighting(entries); // add new overlays
Always register a buffer_closed handler that resets all plugin state when
the buffer is closed by any means (user action, split close, etc.). The
theme editor resets 10+ state fields. Additionally, validate state with
editor.listBuffers() rather than trusting internal flags alone — the
buffer may have been closed externally.
DebouncedSearch from search-utils.ts
(default 150ms) to avoid overwhelming spawnProcess during rapid typingreference_highlight_overlay.rs)Decision: Plugin-based with minimal core changes. All modal editing logic in TypeScript, core provides atomic actions.
Trade-offs:
executeActions() batch API with count support for efficient 3dwCoverage: Movement, count prefix, operators, text objects, visual modes,
colon command mode (30+ commands), repeat (.), find char (f/t/F/T).
Missing: registers and macros (low priority).
Previously: vi-mode-design.md (~900 lines TypeScript)
Decision: Token pipeline integration — compose rendering uses view transforms with conceal ranges and overlays at the token level.
Key principles:
Known issue: Race condition between async plugin hook execution and render state — plugin transforms arrive 1 frame late, showing stale content briefly. Proposed fixes: hold old content during scroll, atomic conceal swap for typing.
See also: markdown.md (remaining work), typora-seamless-canvas-plan.md
(implementation details). Previously also: markdown-compose-vs-glow.md.
Current architecture: Layout cached in render.rs using ratatui's
constraint system. Some components use cached layout (tab bar, status bar);
others hardcode coordinates (menu bar).
Planned evolution (incremental):
menu_bar_row to cached layout for consistencyHitArea and z-index for overlapping UIKey principle: Retained-mode hit testing — rendering produces layout
objects (cached Rects) consumed by input handling on the next frame.
See also: event-dispatch-architecture.md (phases 2-3 pending)
Problem: Auto-opening warning log tabs was intrusive and disruptive.
Decision: Two-tier system:
Architecture: WarningDomain trait allows LSP, plugins, and config to
register custom warning handlers. Generic domain system decouples warning
sources from presentation.
Plugin-based install helpers: Language-specific LSP installation plugins bundled (Python, Rust, TypeScript), user-extensible.
UX principles: Nielsen Norman heuristics — user control/freedom, progressive disclosure.
Previously: warning-notification-ux.md
Decision: Incremental scrollback streaming with append-only backing file.
Dual mode: Terminal mode (live PTY) and Scrollback mode (read-only buffer view with editor navigation).
Performance:
Session persistence: Backing file contains complete scrollback + visible screen snapshot. On restore, load as read-only buffer immediately; replay only if user re-enters terminal mode (deferred).
See also: terminal.md (implementation details)
Key decisions:
Planned consolidation (not yet shipped): Move hardcoded Rust themes to
embedded JSON files (include_str!), validate at CI time via deserialization
test, expose getBuiltinThemes() API for plugins.
Usability issues identified (from testing):
See also: theme-consolidation-plan.md (not yet shipped),
theme-user-flows.md, theme-usability-improvements.md
Problem: Inconsistent width calculations across rendering, navigation, mouse hit testing, and status bar — each reimplements character width logic differently, especially for ANSI escapes, tabs, and zero-width characters.
Decision: Unified visual_layout.rs module with LineMappings struct
providing per-character and per-visual-column indexing.
Design principle: O(1) rendering and hit testing (via pre-computed mappings), O(n) navigation (walk characters per line).
Current fragmentation: Rendering uses ViewLine.char_mappings, mouse
clicks reuse that mapping, but MoveUp/Down uses str_width() on raw buffer
(doesn't understand ANSI, tabs).
See also: visual-layout-unification.md (awaiting implementation)
These principles emerge repeatedly across the designs above: