docs/internal/editor-modules-refactor-plan.md
Target: break up the four mega-files in crates/fresh-editor/src/app/ into
strongly self-contained modules that do not share state with each other via
scattered impl Editor blocks.
Current sizes:
| File | Lines | Top-level types | Methods on Editor |
|---|---|---|---|
mod.rs | 9 605 | 11 (incl. Editor itself, ~67 field groups) | ~158 in one giant impl |
render.rs | 5 394 | 1 enum + 1 const | 42 |
input.rs | 4 138 | 0 | ~50 (handle_key 276 lines, handle_action 1 162 lines / 204 arms) |
buffer_management.rs | 3 464 | 0 | 47 public + 16 private |
The codebase's current convention — files like lsp_actions.rs,
popup_actions.rs, clipboard.rs that contain impl Editor { … } — looks
modular but isn't. Every one of those files can read and write any of
Editor's ~67 field clusters. A test for "clipboard logic" still requires
constructing a whole Editor. Renaming a buffer field still ripples through
every "module". The files are partitioned, the state is not.
Real modularity needs three things the current code lacks:
self.b_field.impl Editor outside the editor file. That single rule prevents
the "scattered god object" pattern from re-emerging.The goal is not "smaller files with impl Editor blocks". It is: replace
Editor with a small composition of owned subsystem structs, give each
subsystem a narrow public API, and confine cross-subsystem coordination to a
thin orchestration layer.
app/editor.rs may contain impl Editor. That
file holds the struct definition, new, tick, and the explicit
cross-subsystem orchestrators (save_active, open_file, close_buffer,
process_async_message, etc.). Nothing else.pub struct FooState (or just
Foo) plus methods on that struct. No method in a subsystem takes
&mut Editor.Editor.Action enum → dispatch per arm". Each arm
is one line that calls one subsystem.These aren't aesthetic preferences — they're what makes individual modules testable and reviewable in isolation. Rule 1 is the keystone: without it, all the others get eroded the next time someone needs "just one quick field".
EditorAfter the refactor, Editor becomes a struct of subsystems — not a flat
soup of fields. Each field is a self-contained module type defined
elsewhere:
// app/editor.rs — the ONLY file with `impl Editor`
pub struct Editor {
// Core data
pub buffers: BufferRegistry,
pub splits: SplitState,
pub view: ViewState, // window flags, terminal size
pub theme: ThemeState,
pub config: Config,
// Language services
pub grammar: GrammarState,
pub lsp: LspState,
pub completion: CompletionState,
pub semantic: SemanticTokensState,
pub hover: HoverState,
// Modal UI
pub prompt: PromptState,
pub menu: MenuState,
pub file_explorer: FileExplorerState,
pub search: SearchState, // search + replace + scan
pub macros: MacroState,
// Editing services
pub clipboard: ClipboardState,
pub bookmarks: BookmarkState,
pub history: PositionHistory,
pub keybindings: KeybindingState,
// I/O & background
pub fs: FilesystemHandles,
pub auto_revert: AutoRevertState,
pub recovery: RecoveryState,
pub stdin_stream: StdinStreamingState,
pub async_io: AsyncIoState, // runtime, bridge, queues
pub plugins: PluginState,
// Cross-cutting
pub status: StatusState,
pub session: SessionState,
pub events: EventBroadcaster,
pub update_check: UpdateChecker,
}
~28 fields, all owned subsystems. Compare to the current 67 raw field
clusters with no encapsulation. Crucially, Rust's split-borrow rules let
you borrow &mut editor.buffers and &mut editor.lsp simultaneously — the
orchestrator code that needs both compiles cleanly.
Each subsystem lives in app/<name>/ as a directory module:
app/lsp/
mod.rs // pub struct LspState + its small public API
diagnostics.rs // free fns / private helpers operating on LspState
progress.rs
requests.rs
server_lifecycle.rs
confirmation_popup.rs // builds a PopupModel; doesn't show it
Inside app/lsp/, files freely access LspState's private fields. Outside
app/lsp/, you can only call its public methods. That is what
"self-contained" means in this design.
The hard problem the current code dodges by using a god object: how does the search subsystem move the cursor? How does saving notify the LSP? Pick one of these four explicit patterns per case. Don't add a fifth.
For operations whose whole purpose is to combine two or three subsystems.
Lives in app/editor.rs:
impl Editor {
pub fn save_active(&mut self) -> Result<()> {
let id = self.splits.active_buffer();
let path = self.buffers.path_of(id)?;
let bytes = self.buffers.serialize(id)?;
self.fs.write(&path, &bytes)?;
self.lsp.notify_did_save(id, &path);
self.recovery.mark_clean(id);
Ok(())
}
}
Five short lines of explicit coordination, instead of a 200-line
&mut self method that reaches into 20 fields. Use this for the named
flows: open/save/close, focus changes, applying an edit event.
When a subsystem method needs to read several others — common in render and in build-prompt-suggestions code:
pub struct RenderCtx<'a> {
pub buffers: &'a BufferRegistry,
pub splits: &'a SplitState,
pub theme: &'a ThemeState,
pub view: &'a ViewState,
pub config: &'a Config,
}
Built once at the top of render, passed by reference into each render fn.
Render fns become pure functions of (RenderCtx, area) -> Vec<Span>. They
are unit-testable without ever constructing an Editor.
When a subsystem method's primary work is internal but it has side effects elsewhere. Replace "this method mutates the LSP and shows a popup" with:
pub enum Effect {
OpenFile(PathBuf),
Status(String),
LspNotifyDidOpen(BufferId),
ShowPopup(PopupModel),
Quit,
}
impl SearchState {
pub fn confirm(&mut self, query: &str) -> Vec<Effect> { ... }
}
The orchestrator (editor_tick or handle_action) drains the returned
Vec<Effect> and applies each. This keeps SearchState testable with zero
dependencies and makes every side effect visible at the call site.
EventBroadcaster)For genuinely fan-out cross-cutting events: buffer changed, file saved, LSP server connected. Subsystems publish; subsystems subscribe. Reserve this for events with ≥3 unrelated consumers — overusing it makes data flow opaque.
Decision rule. Use (a) for known flows, (b) for read-many fan-in, (c) for "write-mostly with side effects", (d) only for truly broadcast lifecycle events. If a method needs none of these, it belongs entirely inside one subsystem and is self-contained.
mod.rs (9 605 lines) → app/editor.rs (~600 lines) + ~25 subsystem dirsapp/editor.rs keeps:
Editor struct definition (subsystem composition).Editor::new, with_working_dir, for_test.editor_tick (the per-frame tick — calls self.lsp.tick(&mut self.buffers),
self.async_io.drain(...), etc.).save_active, open_file,
close_buffer, set_active_buffer, apply_event_to_active_buffer,
process_async_message — each one a short coordinator using mechanism (a).Everything else moves into a subsystem directory under app/. Concretely,
the 11 currently-defined types in mod.rs:
Currently in mod.rs | Moves to |
|---|---|
PendingGrammar | app/grammar/mod.rs (private) |
SemanticTokenRangeRequest, SemanticTokensFullRequestKind, SemanticTokenFullRequest | app/semantic/mod.rs (private) |
FoldingRangeRequest | app/lsp/folding.rs |
DabbrevCycleState | app/completion/dabbrev.rs |
PendingFileOpen | app/async_io/file_opens.rs |
SearchScanState, LineScanState | app/search/scan.rs and app/buffers/line_scan.rs |
StdinStreamingState | app/stdin_stream/mod.rs |
Editor | app/editor.rs |
editor_tick, normalize_path | editor.rs and app/fs/path.rs respectively |
The ~158 methods in the giant impl Editor redistribute by which subsystem
owns the data they touch. Methods that touch one subsystem become methods
on that subsystem. Methods that touch two or three become orchestrators on
Editor. Methods that are actually pure (e.g. normalize_path) become free
functions.
render.rs (5 394 lines) → app/view/ (~3 500 lines, split four ways)The structural mistake in the current render.rs is that it both gathers
state and draws it in the same 1 105-line method, while also containing
search-navigation logic, scroll synchronization, and overlay/popup mutation.
Those don't belong together.
Split along Rule 5:
app/view/
mod.rs // pub fn render(ctx: &RenderCtx, frame: &mut Frame)
model.rs // pure data: ViewModel, PaneModel, GutterModel, StatusModel
build.rs // ViewModel::build(ctx: &RenderCtx) -> ViewModel (pure)
panes.rs // free fn render_pane(model: &PaneModel, area, frame)
gutter.rs // render_gutter(...)
status_bar.rs // render_status_bar(...) + compose_lsp_status (pure)
tabs.rs // render_tabs, render_tab_context_menu, render_drop_zone
popups.rs // render_popups(model: &[PopupModel], ...)
prompt_overlay.rs // render_prompt_popups(...)
hover_overlay.rs // render_hover_highlights(...)
file_explorer.rs // render_explorer (it already exists separately)
Render functions take only what they draw — RenderCtx for fan-in
reads, never &Editor. They contain no mutation. They're testable by
handing in a constructed ViewModel.
The mutating logic currently buried in render.rs extracts to its rightful
subsystem:
Current render.rs content | Moves to |
|---|---|
add_overlay, remove_overlay*, clear_overlays | app/buffers/overlays.rs (overlays live on buffers) |
show_popup, hide_popup, dismiss_transient_popups, scroll_popup, clear_popups, popup nav | app/menu/popups.rs |
show_lsp_confirmation_popup, handle_lsp_confirmation_response, notify_lsp_current_file_opened | app/lsp/confirmation.rs |
build_search_regex, move_cursor_to_match, find_next/previous, find_match_in_direction, expand_regex_replacement (~600 lines, pure) | app/search/{regex.rs,navigation.rs,replace.rs} — pure ones as free fns |
action_to_events, handle_visual_line_movement, collect_lsp_changes, calculate_line_info_for_event | app/editor.rs (orchestrators) and app/view/visual_movement.rs (pure ones) |
sync_scroll_groups, pre_sync_ensure_visible | app/splits/scroll_sync.rs |
notify_lsp_save | app/lsp/notifications.rs |
recompute_layout (mirrors render for macro replay) | call ViewModel::build from the macro path; one less duplicate |
input.rs (4 138 lines) → app/input/ (~800 lines) + redistributed handlersThe current handle_action is 1 162 lines and 204 arms because each arm is
a mini-implementation. Under Rule 6 each arm should be one line.
app/input/
mod.rs // KeyDispatcher::dispatch(key, ctx) -> Option<Action>
context.rs // get_key_context (pure)
key_to_action.rs // resolve key+chord+mode -> Action
chord.rs // chord state (currently a field on Editor)
scrollbar.rs // scrollbar drag math (PURE FNS, see below)
mouse_geometry.rs // screen_to_buffer_position, fold_toggle_byte_from_position (pure)
settings_prompt.rs // SettingsPromptBuilder used by all 14 settings prompts
handle_action itself moves to app/editor.rs and becomes a flat
dispatcher:
impl Editor {
pub fn handle_action(&mut self, action: Action) -> Result<()> {
match action {
Action::Save => self.save_active(),
Action::Cut => self.clipboard.cut(&mut self.buffers, &self.splits),
Action::Search => self.search.start_prompt(&mut self.prompt),
Action::CompletionAccept => self.completion.accept(&mut self.buffers, &self.splits),
Action::SetTheme => self.theme.start_select_prompt(&mut self.prompt),
// ... 200 more single-line arms
}
}
}
No arm contains logic. All 1 162 lines of inline work distribute to the
subsystems they belong to. The settings-prompt cluster (14 nearly-identical
start_*_prompt / apply_* / save_to_config triples, ~850 lines)
collapses onto a SettingsPromptBuilder<T> parametrized over the option
type.
The scrollbar code (~900 lines, 7 methods) is currently &mut self because
it's lazy — it actually only needs
(buffer_lines, viewport, click_position) -> new_viewport. Rewrite as pure
functions in app/input/scrollbar.rs; the 900 lines collapse to maybe 400.
screen_to_buffer_position and friends are already effectively static —
they just happen to be &self for convenience. Promote to free functions
taking (layout, click) -> Position; this immediately decouples them.
buffer_management.rs (3 464 lines) → app/buffers/ (~1 200 lines) + redistributedThe misnomer is that this file isn't really "buffer management" — it's a grab bag of every operation that touches a buffer. Split by the actual owner of the state:
app/buffers/
mod.rs // BufferRegistry: open/close/lookup/serialize, owns buffers map
config_resolve.rs // resolve_line_wrap_for_buffer etc. (pure)
navigation.rs // goto_line_col, select_range, goto_byte_offset (on registry)
line_scan.rs // LineScanManager owns line_scan_state
Then redirect everything else to its rightful module:
Currently in buffer_management.rs | Moves to |
|---|---|
open_file_preview, is_buffer_preview, current_preview | app/preview/mod.rs (new, owns the preview field) |
open_stdin_buffer, poll_stdin_streaming, complete_stdin_streaming, is_stdin_streaming, create_virtual_buffer | app/stdin_stream/mod.rs |
process_search_scan, process_search_scan_batch, finish_search_scan | app/search/scan.rs |
show_warnings_popup, show_lsp_status_popup, build_and_show_lsp_status_popup (315 lines) | app/lsp/status_popup.rs — extract LspStatusPopupBuilder that takes (&LspConfig, &LspServerStatuses, &LspProgress, &Theme) and returns a PopupModel. No Editor access. |
schedule_hot_exit_recovery, queue_file_open, process_pending_file_opens, take_completed_waits, remove_wait_tracking | app/async_io/file_opens.rs |
open_help_manual, open_keyboard_shortcuts | app/help/mod.rs |
open_file, open_local_file, open_file_with_encoding, reload_with_encoding, restore_global_file_state, save_file_state_on_close | app/editor.rs orchestrator (mechanism a) — buffers + splits + lsp + grammar + plugins + filesystem |
close_buffer, force_close_buffer, close_buffer_internal, the six close_*_tab* variants | Same — orchestrators in editor.rs, each broken into named sub-steps (terminal cleanup, focus history adjust, LSP token cleanup, preview adjust) |
switch_buffer, next_buffer, prev_buffer, cycle_tab, navigate_back, navigate_forward | app/splits/navigation.rs (these change focus, which is owned by SplitState) |
get_mouse_hover_state, has_transient_popup, force_check_mouse_hover | app/hover/mod.rs |
What's left in app/buffers/ is what genuinely belongs to "the registry of
open buffers": create, lookup, serialize, basic navigation. ~1 200 lines,
with clear ownership.
Three aspects of the current code make the refactor non-trivial: Rust's borrow-checker, three orchestrator methods that touch almost everything, and the coexistence of the old and new patterns during migration.
Subsystem methods take &mut self (the subsystem). Cross-subsystem
orchestrators look like editor.x.foo(&mut editor.y). Rust's split-borrow
rules permit this as long as the orchestrator destructures — the
typical form:
let Editor { ref mut buffers, ref mut lsp, ref splits, .. } = *self;
buffers.do_a(splits);
lsp.do_b(buffers);
This works and is the standard pattern. There are two cases where it doesn't:
Effect (mechanism c).Ctx (mechanism b) or split into two methods.A related pitfall: don't make subsystems hold back-references to each
other (no Rc<RefCell<...>> cycles, no Arc<Editor> on any subsystem).
All cross-subsystem access must be visible in a function signature. That's
the whole point of Rule 2.
Three methods in today's code touch almost every field. They are the hard cases — attempting to "move" them atomically is where refactors of this shape typically break. Plan each one explicitly.
process_async_messages (mod.rs L4760, ~909 lines, ~20 fields).
Becomes a match over message kind where each arm calls one subsystem:
fn handle_async(&mut self, msg: AsyncMessage) {
match msg {
AsyncMessage::LspResponse(r) => self.lsp.handle_response(r, &mut self.buffers),
AsyncMessage::FileOpened(o) => self.open_file_from_async(o),
AsyncMessage::PluginCommand(c) => self.plugins.handle_command(c, /* ctx */),
AsyncMessage::SearchProgress(p) => self.search.advance_scan(p),
AsyncMessage::FsPoll(p) => self.auto_revert.apply_poll(p, &mut self.buffers),
// ...
}
}
Each arm is one line. The 900 lines of inline handling distribute to the
subsystem that owns the response type. AsyncMessage itself becomes an
enum in app/async_io/messages.rs with one variant per subsystem that can
receive async work.
render (render.rs L150, ~1 105 lines). Becomes "build a ViewModel
(pure fan-in read, ~200 lines), then walk the model (each render fn ~50
lines, no mutation)". A new contributor can read gutter.rs understanding
only GutterModel. This is the transformation described in §5.2 — the key
insight is that today's render is an assembler and a drawer fused
together; split them.
handle_action (input.rs L285, ~1 162 lines, 204 arms). Becomes a flat
dispatcher (shown in §5.3). All inline logic moves to subsystem methods.
If even 200 one-liners becomes unwieldy later, consider a phf::Map< ActionKind, fn(&mut Editor)> — but only after the per-subsystem APIs are
sized correctly. Premature table-dispatch is its own anti-pattern.
Some of the god-object behaviour currently holds invariants implicitly. Make these explicit before splitting:
active_buffer,
adjust viewport, update position history, and notify LSP in one atomic
sequence. Under the new design this becomes Editor::set_active_buffer,
a named orchestrator. Any subsystem that wants to change focus goes
through it.promote_current_preview()
calls at the top of ~15 methods. Under the new design, PreviewState
owns this and Editor orchestrators call self.preview.promote_if_any( &mut self.buffers, &mut self.splits) as a named prelude.apply_action_as_events currently wraps a sequence
of events in an undo boundary. The new design moves this wrapping into
BufferRegistry::apply_events_as_batch(events) — the transactional
shape is explicit.log_and_apply_event
so the event stream can be replayed. The new BufferRegistry::apply_event
is the single choke-point that logs.These invariants used to hold because one struct owned everything and could enforce discipline ad hoc. Post-refactor they hold because there is exactly one named choke-point per invariant, in a named file, covered by a test.
The end state above is structural — you don't get there in one PR. But you
can get there incrementally without ever leaving main in a broken state,
by going subsystem-by-subsystem in this order. Each phase lands as one or
more commits; each commit compiles, passes tests, and is individually
revertable.
Extract the functions that are already effectively pure but happen to be
&self methods for convenience. No new types, no new state ownership.
Candidates (from the survey):
compose_lsp_status (render.rs L30–146) — already pure, just move.expand_regex_replacement (render.rs L3432–4019, ~600 lines) — pure
backreference expansion.build_search_regex, build_replace_regex, get_regex_match_len.screen_to_buffer_position, adjust_content_rect_for_compose,
fold_toggle_byte_from_position (input.rs L2359–2596).smart_home_visual_line (render.rs L4022–4072).normalize_path (mod.rs L126–154).calculate_scrollbar_jump_visual,
calculate_scrollbar_drag_relative_visual.resolve_line_wrap_for_buffer, resolve_page_view_for_buffer,
resolve_wrap_column_for_buffer (buffer_management.rs L26–73).byte_to_2d (currently in clipboard.rs).Each of these becomes a free function in an appropriate module file. ~1 500 lines decoupled with effectively zero risk. This phase also proves the pure-function pattern and surfaces any hidden state dependencies before touching real structs.
Risk: trivial. Blast radius: each call site changes from
self.foo(...) to module::foo(...).
Extract the smallest state clusters that are touched by ≤ 5 call sites. These establish the subsystem pattern before tackling anything cross-cutting.
In order of increasing complexity:
MacroState (4 fields: macros, macro_recording, last_macro_register,
macro_playing). Record/replay cluster.BookmarkState (2 fields: bookmarks, active_custom_contexts).HoverState (~4 fields: hover_symbol_range, hover_symbol_overlay,
mouse_hover_screen_position, pending_hover_request).ClipboardState (just clipboard, already externally-typed).StdinStreamingState (1 field, plus the 5 methods in buffer_management.rs).LineScanManager (1 field, 6 methods).SearchScanManager (1 field, 3 methods).UpdateChecker (1 field, a couple of methods).BackgroundFade + ThemeCache into ThemeState.Each lands as its own commit. Template for each:
app/<name>/mod.rs with the state struct and moved methods.Editor.impl Editor methods that operated on them.self.foo → self.<name>.foo).cargo build && cargo test green.Risk: low per subsystem. Blast radius: ≤ 10 call sites each.
app/view/Introduce ViewModel as pure data. Build it inside the current render
method, then progressively move each sub-render (gutter, tabs, status bar,
popups) to take only its slice of the model. Each sub-render move is a
separate commit.
Order within the phase:
ViewModel, PaneModel, GutterModel, StatusModel,
TabModel, PopupModel in app/view/model.rs. No behaviour change.RenderCtx<'a> read-only bundle.compose_lsp_status into status_bar.rs (free fn).render_status_bar(model: &StatusModel, theme: &ThemeState, ...)
to app/view/status_bar.rs.render_gutter.render_tabs, render_tab_context_menu, render_tab_drop_zone.render_popups, popup nav.render_prompt_popups.render_hover_highlights.recompute_layout — call ViewModel::build from the macro replay
path instead.Risk: medium. The main render method is the single biggest hotspot; do
it step-by-step with visual regression tests running between each commit.
The existing visual-regression harness (under docs/visual-regression) is
the safety net.
handle_actionNot a single commit — one commit per arm group. Each commit absorbs ~10–20
Action::* arms into their respective subsystem, shrinking the giant match
by that much.
Order: easy groups first (clipboard, macros, bookmarks, theme selection) to establish the pattern, then the harder ones (search, LSP, completion, prompt lifecycle).
Risk: low per commit. Each arm is isolated; regressions surface as single broken actions. Blast radius: one action per commit.
With handle_action already thin, extract the remaining input code:
app/input/scrollbar.rs.mouse_geometry pure fns.SettingsPromptBuilder<T> and collapse the 14 settings-prompt
triples into it.get_key_context and handle_key to app/input/dispatch.rs.app/input/chord.rs (own field on Editor).Risk: low-medium.
With subsystems in place, redirect the contents of buffer_management.rs
to their rightful owners:
PreviewState (owns preview promotion invariant).app/stdin_stream/.LspStatusPopupBuilder to app/lsp/status_popup.rs as a pure
builder returning a PopupModel.app/splits/navigation.rs.open_file / close_buffer as named orchestrators in
app/editor.rs with sub-steps (terminal cleanup, focus history, LSP
cleanup, preview adjust).Risk: medium. close_buffer_internal is 234 lines with 10+ field
touches — break into named local helpers on Editor first, then move each
helper's body onto the subsystem that owns the state it manipulates.
The remaining big state clusters, tackled last because they are read by many orchestrators:
SearchState (consolidates both the search prompt and the scan manager).CompletionState + SemanticTokensState.PromptState + FileOpenState (consolidate existing prompt_actions.rs,
file_open*.rs).PluginState (large — last).LspState (largest — very last; ~25 fields, read by render, async
messages, save orchestrator).Risk: medium-high. These are cross-cutting and will surface the most
&mut/borrow rearrangement work. Phases 1–6 have already built up the
machinery (Effects, RenderCtx, named orchestrators) so by this point the
pattern is well-established.
app/*_actions.rs files that have been fully absorbed.mod.rs content to app/editor.rs; leave mod.rs as a module
index (pub mod editor; pub use editor::Editor; plus sub-module
re-exports).rg "impl Editor" crates/fresh-editor/src/app/ must return only
app/editor.rs. This is the acceptance criterion for Rule 1.use super::Editor (only
app/editor.rs imports subsystem types, not the other way round).After all phases:
| File / directory | Before | After |
|---|---|---|
app/mod.rs | 9 605 | ~50 (re-exports) |
app/editor.rs | — | ~600 (struct + orchestrators) |
app/view/ | 5 394 | ~3 500 (split ~11 ways) |
app/input/ | 4 138 | ~800 (split ~7 ways) |
app/buffers/ | 3 464 | ~1 200 |
app/lsp/ | existing | ~2 800 absorbs scattered logic |
app/search/, etc. | — | each subsystem 200–800 |
Total app/ | ~45 flat files, 22 601 LoC in 4 mega-files | ~28 subsystem dirs, largest single file ~800 LoC |
Acceptance criteria: Rule 1 audit passes; each subsystem has unit tests
that construct only its own state; the render fns are pure and testable
against a hand-constructed ViewModel; handle_action's body fits on one
screen.