fresh/app/editor_init.rs
1//! Editor construction and initialization.
2//!
3//! `Editor::new` and friends — the entry points that take a configuration,
4//! terminal dimensions, color capability, and filesystem implementation
5//! and return a ready-to-use Editor with every field initialized.
6//!
7//! Also includes `start_background_grammar_build`, which kicks off the
8//! initial grammar registry build asynchronously so startup doesn't block.
9
10// Re-use everything mod.rs imports — the constructors touch every field
11// on Editor and most of the types in the module.
12use super::*;
13
14/// Phase-timing helper used when `FRESH_TEST_TIMING=1` is set so test
15/// authors can see where `Editor::with_options` spends its wall clock.
16/// No-op when the env var is unset; printed to stderr otherwise.
17struct InitTimer {
18 label: &'static str,
19 start: std::time::Instant,
20 last: std::time::Instant,
21 enabled: bool,
22}
23
24impl InitTimer {
25 fn start(label: &'static str) -> Self {
26 let enabled = std::env::var("FRESH_TEST_TIMING").is_ok_and(|v| !v.is_empty() && v != "0");
27 let now = std::time::Instant::now();
28 if enabled {
29 eprintln!("[timing] {label} start");
30 }
31 Self {
32 label,
33 start: now,
34 last: now,
35 enabled,
36 }
37 }
38 fn phase(&mut self, name: &str) {
39 if !self.enabled {
40 return;
41 }
42 let now = std::time::Instant::now();
43 let delta = now.duration_since(self.last);
44 let cumul = now.duration_since(self.start);
45 eprintln!(
46 "[timing] {name:<30} +{delta:>8.1}ms (cumul {cumul:.1}ms)",
47 name = name,
48 delta = delta.as_secs_f64() * 1000.0,
49 cumul = cumul.as_secs_f64() * 1000.0,
50 );
51 self.last = now;
52 }
53 fn finish(self) {
54 if !self.enabled {
55 return;
56 }
57 eprintln!(
58 "[timing] {label} total {total:.1}ms",
59 label = self.label,
60 total = self.start.elapsed().as_secs_f64() * 1000.0,
61 );
62 }
63}
64
65/// Set a value at a dot-separated path inside a JSON object, creating
66/// intermediate maps as needed.
67fn set_dot_path(root: &mut serde_json::Value, path: &str, value: serde_json::Value) {
68 let segments: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
69 if segments.is_empty() {
70 return;
71 }
72 let mut cur = root;
73 for seg in &segments[..segments.len() - 1] {
74 if !cur.is_object() {
75 *cur = serde_json::Value::Object(serde_json::Map::new());
76 }
77 cur = cur
78 .as_object_mut()
79 .unwrap()
80 .entry((*seg).to_string())
81 .or_insert(serde_json::Value::Null);
82 }
83 let last = segments[segments.len() - 1];
84 if !cur.is_object() {
85 *cur = serde_json::Value::Object(serde_json::Map::new());
86 }
87 cur.as_object_mut().unwrap().insert(last.to_string(), value);
88}
89
90/// Pre-built non-trivial inputs handed to [`Editor::from_parts`].
91///
92/// Everything in here either depends on external resources (filesystem,
93/// config, plugins, themes, terminal dimensions, …) or is one of the
94/// few editor-global fields a caller wants to control directly — most
95/// notably the initial set of `windows`. Trivial fields (counters at
96/// zero, empty collections, `None` options, registries built from
97/// scratch with no dependencies) are filled in by the constructor.
98///
99/// The factory methods (`Editor::new`, `Editor::with_working_dir`,
100/// `Editor::with_working_dir_opts`, `Editor::for_test`,
101/// `Editor::with_options`) build a value of this type and pass it to
102/// `Editor::from_parts`. No production code constructs `Editor`
103/// without going through `from_parts`, so adding a field here forces
104/// every factory to provide it.
105pub(super) struct EditorParts {
106 // Config / paths
107 pub(super) config: Arc<Config>,
108 pub(super) config_snapshot_anchor: Arc<Config>,
109 pub(super) config_cached_json: Arc<serde_json::Value>,
110 pub(super) user_config_raw: Arc<serde_json::Value>,
111 pub(super) dir_context: DirectoryContext,
112
113 // Themes
114 pub(super) theme: Arc<RwLock<crate::view::theme::Theme>>,
115 pub(super) theme_registry: Arc<crate::view::theme::ThemeRegistry>,
116 pub(super) theme_cache: Arc<RwLock<HashMap<String, serde_json::Value>>>,
117
118 // Grammar
119 pub(super) grammar_registry: Arc<crate::primitives::grammar::GrammarRegistry>,
120 pub(super) pending_grammars: Vec<PendingGrammar>,
121 pub(super) needs_full_grammar_build: bool,
122
123 // Keybindings + buffer-id allocation
124 pub(super) keybindings: Arc<RwLock<KeybindingResolver>>,
125 pub(super) buffer_id_alloc: crate::app::window_resources::BufferIdAllocator,
126 pub(super) next_buffer_id: usize,
127
128 // Terminal
129 pub(super) terminal_width: u16,
130 pub(super) terminal_height: u16,
131 pub(super) color_capability: crate::view::color_support::ColorCapability,
132
133 // Async / IO
134 pub(super) tokio_runtime: Option<Arc<tokio::runtime::Runtime>>,
135 pub(super) async_bridge: AsyncBridge,
136 pub(super) local_filesystem: Arc<dyn FileSystem + Send + Sync>,
137
138 // Chrome flags resolved from config
139
140 // Windows — the whole point of the split: the factory builds these
141 // (from disk persistence or a single seed window), the constructor
142 // just installs them.
143 pub(super) windows: HashMap<fresh_core::WindowId, crate::app::window::Window>,
144 pub(super) active_window: fresh_core::WindowId,
145 pub(super) next_window_id: u64,
146
147 // Registries / managers
148 pub(super) command_registry: Arc<RwLock<CommandRegistry>>,
149 pub(super) quick_open_registry: QuickOpenRegistry,
150 pub(super) plugin_manager: Arc<RwLock<PluginManager>>,
151 pub(super) recovery_service: Arc<std::sync::Mutex<RecoveryService>>,
152 pub(super) key_translator: crate::input::key_translator::KeyTranslator,
153 pub(super) update_checker: Option<crate::services::release_checker::PeriodicUpdateChecker>,
154
155 // Time
156 pub(super) time_source: SharedTimeSource,
157
158 // Persisted plugin global state (one map per plugin). Pulled from
159 // `<data_dir>/orchestrator/state/<plugin>.json` by the
160 // factory so plugins reading `getGlobalState(...)` on first tick
161 // see the previous run's values without a separate
162 // post-construction load step.
163 pub(super) plugin_global_state: HashMap<String, HashMap<String, serde_json::Value>>,
164
165 /// Per-plugin config schemas discovered from `<plugin>.schema.json` sidecars.
166 pub(super) plugin_schemas: HashMap<String, serde_json::Value>,
167
168 /// Editor-wide event broadcaster, shared with every WindowResources.
169 pub(super) event_broadcaster: crate::model::control_event::EventBroadcaster,
170}
171
172impl Editor {
173 /// Lightweight constructor. Takes the non-trivial editor-global
174 /// resources via [`EditorParts`] and fills in every other field
175 /// with its empty/default value. No I/O, no plugin loading, no
176 /// disk reads happen here — that's all the factory's job
177 /// ([`Editor::with_options`] and friends), so this method can
178 /// also serve as a building block for narrowly-scoped tests that
179 /// want to assemble an `Editor` from hand-built parts.
180 ///
181 /// Fields that need a `time_source` for their initial value
182 /// (auto-revert timestamps, etc.) read it out of `parts` rather
183 /// than capturing a new clock — so two editors built from the
184 /// same parts agree on "now".
185 pub(super) fn from_parts(parts: EditorParts) -> Self {
186 Editor {
187 // From parts (non-trivial):
188 next_buffer_id: parts.next_buffer_id,
189 buffer_id_alloc: parts.buffer_id_alloc,
190 config: parts.config,
191 config_snapshot_anchor: parts.config_snapshot_anchor,
192 config_cached_json: parts.config_cached_json,
193 user_config_raw: parts.user_config_raw,
194 dir_context: parts.dir_context.clone(),
195 grammar_registry: parts.grammar_registry,
196 pending_grammars: parts.pending_grammars,
197 needs_full_grammar_build: parts.needs_full_grammar_build,
198 theme: parts.theme,
199 theme_registry: parts.theme_registry,
200 theme_cache: parts.theme_cache,
201 keybindings: parts.keybindings,
202 terminal_width: parts.terminal_width,
203 terminal_height: parts.terminal_height,
204 last_layout_signature: None,
205 tokio_runtime: parts.tokio_runtime,
206 async_bridge: Some(parts.async_bridge),
207 paste_pending: std::collections::HashMap::new(),
208 paste_slow_path_just_armed: false,
209 paste_render_suppress_until: None,
210 local_filesystem: parts.local_filesystem,
211 menu_state: crate::view::ui::MenuState::new(parts.dir_context.themes_dir()),
212 windows: parts.windows,
213 session_keepalives: HashMap::new(),
214 remote_attach_inflight: std::collections::HashSet::new(),
215 remote_attach_cancelled: std::collections::HashSet::new(),
216 remote_attach_cancels: std::collections::HashMap::new(),
217 active_window: parts.active_window,
218 next_window_id: parts.next_window_id,
219 window_cycle_order: None,
220 command_registry: parts.command_registry,
221 quick_open_registry: parts.quick_open_registry,
222 plugin_manager: parts.plugin_manager,
223 recovery_service: parts.recovery_service,
224 time_source: parts.time_source,
225 color_capability: parts.color_capability,
226 update_checker: parts.update_checker,
227 key_translator: parts.key_translator,
228
229 // Trivial defaults (no external dependencies):
230 materialize_pending: std::collections::HashSet::new(),
231 grammar_reload_pending: false,
232 grammar_build_in_progress: false,
233 pending_grammar_callbacks: Vec::new(),
234 expanded_menus_cache: crate::view::ui::ExpandedMenusCache::default(),
235 ansi_background: None,
236 ansi_background_path: None,
237 background_fade: crate::primitives::ansi_background::DEFAULT_BACKGROUND_FADE,
238 clipboard: crate::services::clipboard::Clipboard::new(),
239 should_quit: false,
240 workspace_trust_prompt_cancellable: false,
241 workspace_trust_markers: Vec::new(),
242 workspace_trust_scroll: 0,
243 should_detach: false,
244 session_mode: false,
245 software_cursor_only: false,
246 session_name: None,
247 pending_escape_sequences: Vec::new(),
248 restart_with_dir: None,
249 last_window_title: None,
250 mode_registry: ModeRegistry::new(),
251 pending_authority: None,
252 pending_keepalive: None,
253 remote_indicator_override: None,
254 menus: crate::config::MenuConfig::translated(),
255 background_process_handles: HashMap::new(),
256 host_process_handles: HashMap::new(),
257 status_bar_token_registry: Mutex::new(HashMap::new()),
258 plugin_schemas: std::sync::Arc::new(std::sync::RwLock::new(parts.plugin_schemas)),
259 event_broadcaster: parts.event_broadcaster,
260 #[cfg(feature = "plugins")]
261 pending_plugin_actions: Vec::new(),
262 #[cfg(feature = "plugins")]
263 plugin_render_requested: false,
264 full_redraw_requested: false,
265 suspend_requested: false,
266 plugin_global_state: parts.plugin_global_state,
267 warning_log: None,
268 status_log_path: None,
269 #[cfg(feature = "plugins")]
270 file_watcher_manager: crate::services::file_watcher::FileWatcherManager::new(),
271 last_path_change_for_test: None,
272 last_watch_response_for_test: None,
273 preview_window_id: None,
274 settings_state: None,
275 calibration_wizard: None,
276 // event_debug moved to Window
277 keybinding_editor: None,
278 stdin_stream: stdin_stream::StdinStream::default(),
279 global_popups: crate::view::popup::PopupManager::new(),
280 previous_cursor_screen_pos: None,
281 cursor_jump_animation: None,
282 pending_vb_animations: Vec::new(),
283 widget_registry: crate::widgets::WidgetRegistry::new(),
284 floating_widget_panel: None,
285 dock: None,
286 dock_width: None,
287 dock_resizing: false,
288 }
289 }
290
291 /// Create a new editor with the given configuration and terminal dimensions
292 /// Uses system directories for state (recovery, sessions, etc.)
293 pub fn new(
294 config: Config,
295 width: u16,
296 height: u16,
297 dir_context: DirectoryContext,
298 color_capability: crate::view::color_support::ColorCapability,
299 filesystem: Arc<dyn FileSystem + Send + Sync>,
300 ) -> AnyhowResult<Self> {
301 Self::with_working_dir(
302 config,
303 width,
304 height,
305 None,
306 dir_context,
307 true,
308 color_capability,
309 filesystem,
310 )
311 }
312
313 /// Create a new editor with an explicit working directory
314 /// This is useful for testing with isolated temporary directories
315 #[allow(clippy::too_many_arguments)]
316 pub fn with_working_dir(
317 config: Config,
318 width: u16,
319 height: u16,
320 working_dir: Option<PathBuf>,
321 dir_context: DirectoryContext,
322 plugins_enabled: bool,
323 color_capability: crate::view::color_support::ColorCapability,
324 filesystem: Arc<dyn FileSystem + Send + Sync>,
325 ) -> AnyhowResult<Self> {
326 // Convenience constructor (tests, and any caller that only has a
327 // filesystem to inject): the editor's real authority *is* a local one
328 // backed by that filesystem. Build it here so the editor is still
329 // constructed with the authority it runs under — production callers
330 // that own a non-local authority pass it straight to
331 // `with_working_dir_opts` instead.
332 let authority = Self::local_authority_with_filesystem(filesystem);
333 Self::with_working_dir_opts(
334 config,
335 width,
336 height,
337 working_dir,
338 dir_context,
339 plugins_enabled,
340 color_capability,
341 authority,
342 false,
343 )
344 }
345
346 /// Like [`Self::with_working_dir`] but with `defer_plugin_load`
347 /// exposed. When `true`, plugin loading is dispatched to the plugin
348 /// thread and the constructor returns immediately; results arrive
349 /// later via `AsyncMessage::PluginsDirLoaded` /
350 /// `PluginDeclarationsReady` and are applied in `process_async_messages`.
351 /// Used by the TUI startup path so the first frame draws without
352 /// waiting on TS parse/transpile/register.
353 #[allow(clippy::too_many_arguments)]
354 pub fn with_working_dir_opts(
355 config: Config,
356 width: u16,
357 height: u16,
358 working_dir: Option<PathBuf>,
359 dir_context: DirectoryContext,
360 plugins_enabled: bool,
361 color_capability: crate::view::color_support::ColorCapability,
362 authority: crate::services::authority::Authority,
363 defer_plugin_load: bool,
364 ) -> AnyhowResult<Self> {
365 tracing::info!("Building default grammar registry...");
366 let start = std::time::Instant::now();
367 let mut grammar_registry = crate::primitives::grammar::GrammarRegistry::defaults_only();
368 // Merge user config so find_by_path respects user globs/filenames
369 // from the very first lookup. `defaults_only` just built the Arc, so
370 // we're the sole owner; get_mut is guaranteed to succeed. Assert
371 // rather than silently drop config — a failure here would leave the
372 // user wondering why their `*.conf → bash` rule doesn't highlight.
373 std::sync::Arc::get_mut(&mut grammar_registry)
374 .expect("defaults_only returned a shared Arc")
375 .apply_language_config(&config.languages);
376 crate::config::reload_indent_overrides(&config.languages);
377 tracing::info!("Default grammar registry built in {:?}", start.elapsed());
378 // Don't start background grammar build here — it's deferred to the
379 // first flush_pending_grammars() call so that plugin-registered grammars
380 // from the first event-loop tick are included in a single build.
381 Self::with_options(
382 config,
383 width,
384 height,
385 working_dir,
386 authority,
387 plugins_enabled,
388 true, // enable_embedded_plugins (production: always allow embedded fallback)
389 dir_context,
390 None,
391 color_capability,
392 grammar_registry,
393 defer_plugin_load,
394 )
395 }
396
397 /// Create a new editor for testing with custom backends
398 ///
399 /// By default uses empty grammar registry for fast initialization.
400 /// Pass `Some(registry)` for tests that need syntax highlighting or shebang detection.
401 ///
402 /// `enable_plugins` controls whether the plugin runtime is active at all.
403 /// `enable_embedded_plugins` separately gates the cargo-binstall embedded
404 /// plugins fallback — tests that pre-populate `<config_dir>/plugins/` and
405 /// want exact control over which plugins load can pass `false` here while
406 /// keeping `enable_plugins = true`.
407 #[allow(clippy::too_many_arguments)]
408 pub fn for_test(
409 config: Config,
410 width: u16,
411 height: u16,
412 working_dir: Option<PathBuf>,
413 dir_context: DirectoryContext,
414 color_capability: crate::view::color_support::ColorCapability,
415 filesystem: Arc<dyn FileSystem + Send + Sync>,
416 time_source: Option<SharedTimeSource>,
417 grammar_registry: Option<Arc<crate::primitives::grammar::GrammarRegistry>>,
418 enable_plugins: bool,
419 enable_embedded_plugins: bool,
420 ) -> AnyhowResult<Self> {
421 let mut grammar_registry =
422 grammar_registry.unwrap_or_else(crate::primitives::grammar::GrammarRegistry::empty);
423 // Merge user `[languages]` config into the catalog — production code
424 // does this at startup and again after the background grammar build,
425 // tests need the same so config-declared grammars/extensions resolve
426 // through `find_by_path`. Both call sites that feed into `for_test`
427 // (`HarnessOptions::with_full_grammar_registry` and the default
428 // `GrammarRegistry::empty()`) hand us the sole Arc owner.
429 std::sync::Arc::get_mut(&mut grammar_registry)
430 .expect("grammar registry Arc must be uniquely owned at for_test entry")
431 .apply_language_config(&config.languages);
432 crate::config::reload_indent_overrides(&config.languages);
433 let authority = Self::local_authority_with_filesystem(filesystem);
434 let mut editor = Self::with_options(
435 config,
436 width,
437 height,
438 working_dir,
439 authority,
440 enable_plugins,
441 enable_embedded_plugins,
442 dir_context,
443 time_source,
444 color_capability,
445 grammar_registry,
446 false,
447 )?;
448 // Tests typically have no async_bridge, so the deferred grammar build
449 // would just drain pending_grammars and early-return. Skip it entirely.
450 editor.needs_full_grammar_build = false;
451 Ok(editor)
452 }
453
454 /// Build a local authority whose filesystem is the supplied one.
455 ///
456 /// The bridge for callers that only have a `FileSystem` to inject (the
457 /// `new` / `with_working_dir` / `for_test` convenience constructors): a
458 /// local-backed authority *is* the real authority such an editor runs
459 /// under, so this is construction with the true authority, not a
460 /// placeholder destined to be replaced. Carries a permissive trust and an
461 /// inactive env provider — the defaults `Authority::local` uses for the
462 /// host backend.
463 fn local_authority_with_filesystem(
464 filesystem: Arc<dyn FileSystem + Send + Sync>,
465 ) -> crate::services::authority::Authority {
466 crate::services::authority::Authority {
467 filesystem,
468 ..crate::services::authority::Authority::local(
469 Arc::new(crate::services::workspace_trust::WorkspaceTrust::permissive()),
470 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
471 )
472 }
473 }
474
475 /// Create a new editor with custom options
476 /// This is primarily used for testing with slow or mock backends
477 /// to verify editor behavior under various I/O conditions
478 #[allow(clippy::too_many_arguments)]
479 fn with_options(
480 mut config: Config,
481 width: u16,
482 height: u16,
483 working_dir: Option<PathBuf>,
484 authority: crate::services::authority::Authority,
485 enable_plugins: bool,
486 #[cfg_attr(not(feature = "embed-plugins"), allow(unused_variables))]
487 enable_embedded_plugins: bool,
488 dir_context: DirectoryContext,
489 time_source: Option<SharedTimeSource>,
490 color_capability: crate::view::color_support::ColorCapability,
491 grammar_registry: Arc<crate::primitives::grammar::GrammarRegistry>,
492 defer_plugin_load: bool,
493 ) -> AnyhowResult<Self> {
494 let mut t = InitTimer::start("Editor::with_options");
495 // The editor is constructed with the *real* authority it will run
496 // under — never a local placeholder that gets replaced later (that
497 // left a window where, e.g., quick-open's `git ls-files` ran through
498 // the local spawner while the filesystem was already remote). The
499 // filesystem is derived from it; the spawner/long-running/terminal
500 // ride along on `self.authority`.
501 let filesystem = std::sync::Arc::clone(&authority.filesystem);
502 // Use provided time_source or default to RealTimeSource
503 let time_source = time_source.unwrap_or_else(RealTimeSource::shared);
504 tracing::info!("Editor::new called with width={}, height={}", width, height);
505
506 // Use provided working_dir or capture from environment
507 let working_dir = working_dir
508 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
509
510 // Canonicalize working_dir to resolve symlinks and normalize path components
511 // This ensures consistent path comparisons throughout the editor
512 let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
513
514 t.phase("preamble");
515 // Load all themes into registry
516 tracing::info!("Loading themes...");
517 let theme_loader = crate::view::theme::ThemeLoader::new(dir_context.themes_dir());
518 t.phase("ThemeLoader::new");
519 // Scan installed packages (language packs + bundles) before plugin loading.
520 // This replaces the JS loadInstalledPackages() — configs, grammars, plugin dirs,
521 // and theme dirs are all collected here and applied synchronously.
522 let scan_result =
523 crate::services::packages::scan_installed_packages(&dir_context.config_dir);
524 t.phase("scan_installed_packages");
525
526 // Apply package language configs (user config takes priority via or_insert)
527 for (lang_id, lang_config) in &scan_result.language_configs {
528 config
529 .languages
530 .entry(lang_id.clone())
531 .or_insert_with(|| lang_config.clone());
532 }
533
534 // Apply package LSP configs (user config takes priority via or_insert)
535 for (lang_id, lsp_config) in &scan_result.lsp_configs {
536 config
537 .lsp
538 .entry(lang_id.clone())
539 .or_insert_with(|| LspLanguageConfig::Multi(vec![lsp_config.clone()]));
540 }
541
542 let theme_registry = Arc::new(theme_loader.load_all(&scan_result.bundle_theme_dirs));
543 t.phase("theme_loader.load_all");
544 tracing::info!("Themes loaded");
545
546 // Get active theme from registry, falling back to default if not found
547 let theme_inner = theme_registry.get_cloned(&config.theme).unwrap_or_else(|| {
548 tracing::warn!(
549 "Theme '{}' not found, falling back to default theme",
550 config.theme.0
551 );
552 theme_registry
553 .get_cloned(&crate::config::ThemeName(
554 crate::view::theme::THEME_HIGH_CONTRAST.to_string(),
555 ))
556 .expect("Default theme must exist")
557 });
558
559 // Set terminal cursor color to match theme
560 theme_inner.set_terminal_cursor_color();
561 let theme = Arc::new(RwLock::new(theme_inner));
562
563 t.phase("theme_setup");
564 let keybindings = Arc::new(RwLock::new(KeybindingResolver::new(&config)));
565 t.phase("keybindings");
566
567 // Create an empty initial buffer
568 let mut buffers = crate::app::window::WindowBuffers::new();
569 let mut event_logs = HashMap::new();
570
571 // Buffer IDs start at 1 (not 0) because the plugin API returns 0 to
572 // mean "no active buffer" from getActiveBufferId(). JavaScript treats
573 // 0 as falsy (`if (!bufferId)` would wrongly reject buffer 0), so
574 // using 1-based IDs avoids this entire class of bugs in plugins.
575 let buffer_id = BufferId(1);
576 let mut state = EditorState::new(
577 width,
578 height,
579 config.editor.large_file_threshold_bytes as usize,
580 Arc::clone(&filesystem),
581 );
582 // Configure initial buffer settings from config
583 state
584 .margins
585 .configure_for_line_numbers(config.editor.line_numbers);
586 state.buffer_settings.tab_size = config.editor.tab_size;
587 state.buffer_settings.auto_close = config.editor.auto_close;
588 // Note: line_wrap_enabled is now stored in SplitViewState.viewport
589 tracing::info!("EditorState created for buffer {:?}", buffer_id);
590 buffers.insert(buffer_id, state);
591 event_logs.insert(buffer_id, EventLog::new());
592
593 // Create metadata for the initial empty buffer. After Step 0l
594 // this lives on the base `Window`; we accumulate it locally and
595 // hand it off when the window is constructed below.
596 let mut buffer_metadata: HashMap<BufferId, BufferMetadata> = HashMap::new();
597 buffer_metadata.insert(buffer_id, BufferMetadata::new());
598
599 // Read orchestrator persistence (`windows.json` and
600 // `state/*.json` under `<data_dir>/orchestrator/`)
601 // before the LSP and base-window construction below.
602 // Pulling persistence in here lets the factory build the
603 // right windows up front: previously this ran from
604 // `main.rs` after construction, so the freshly built
605 // single-base window had to be torn down and replaced with
606 // an inert shell — leaving the active window with
607 // `splits = None` until something re-seeded it. Now the
608 // factory picks the persisted active id/root, attaches the
609 // seed buffer + LSP to it directly, and the constructor
610 // sees a well-formed windows map.
611 let persisted_env = crate::app::orchestrator_persistence::read_persisted_windows_env(
612 filesystem.as_ref(),
613 &dir_context.data_dir,
614 &working_dir,
615 );
616 let plugin_global_state = crate::app::orchestrator_persistence::read_persisted_plugin_state(
617 filesystem.as_ref(),
618 &dir_context.data_dir,
619 &working_dir,
620 );
621
622 // Reopen the session the user last used *in this project*, if
623 // any — never a session from another project. Cross-project
624 // restore is what dragged yesterday's directories/files into a
625 // different project's window; `pick_active_window_for_cwd` only
626 // ever returns a window rooted at `working_dir`, so launching
627 // elsewhere can't pull this project's sessions in (and vice
628 // versa). When the cwd has no sessions, fall back to a clean
629 // base window (id 1) at the launch cwd. This also keeps the LSP
630 // / Open-Terminal default pointed at the launch cwd (issue
631 // #2026).
632 let picked_active = crate::app::orchestrator_persistence::pick_active_window_for_cwd(
633 persisted_env.as_ref(),
634 &working_dir,
635 );
636 let (active_window_id, _active_window_root) = picked_active
637 .map(|w| (fresh_core::WindowId(w.id), w.root.clone()))
638 .unwrap_or((fresh_core::WindowId(1), working_dir.clone()));
639
640 t.phase("buffer_state");
641 // Create Tokio runtime for async I/O (LSP, file watching, git, etc.)
642 let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
643 .worker_threads(2) // Small pool for I/O tasks
644 .thread_name("editor-async")
645 .enable_all()
646 .build()
647 .ok()
648 .map(Arc::new);
649 t.phase("tokio_runtime");
650
651 // Create editor-global async bridge for editor-scoped async
652 // sources (plugin runtime callbacks, file-open dialog, etc.).
653 // Per-window subsystems (LSP, terminal output, file-explorer
654 // async expansion) flow through their owning window's
655 // bridge instead — see `Window.bridge`.
656 let async_bridge = AsyncBridge::new();
657 let event_broadcaster = crate::model::control_event::EventBroadcaster::default();
658
659 if tokio_runtime.is_none() {
660 tracing::warn!("Failed to create Tokio runtime - async features disabled");
661 }
662
663 // The base window's LSP manager is built by `Window::new`
664 // (rooted at the window's root, wired to its own bridge), just
665 // like every other window — there is no special boot-time LSP
666 // construction here anymore. See `build_window_lsp`.
667
668 t.phase("lsp_setup");
669 // Initialize split manager with the initial buffer
670 let split_manager = SplitManager::new(buffer_id);
671
672 // Initialize per-split view state for the initial split
673 let mut split_view_states = HashMap::new();
674 let initial_split_id = split_manager.active_split();
675 let mut initial_view_state = SplitViewState::with_buffer(width, height, buffer_id);
676 initial_view_state.apply_config_defaults(
677 config.editor.line_numbers,
678 config.editor.highlight_current_line,
679 config.editor.line_wrap,
680 config.editor.wrap_indent,
681 config.editor.wrap_column,
682 config.editor.rulers.clone(),
683 config.editor.scroll_offset,
684 );
685 split_view_states.insert(initial_split_id, initial_view_state);
686
687 // Initialize filesystem manager for file explorer
688 let fs_manager = Arc::new(FsManager::new(Arc::clone(&filesystem)));
689
690 // Initialize command registry (always available, used by both plugins and core)
691 let command_registry = Arc::new(RwLock::new(CommandRegistry::new()));
692
693 // The authority is the *real* one this editor runs under, handed in
694 // by the caller — not a local placeholder swapped out later. Every
695 // backend-derived seam below (quick-open's file provider, the LSP
696 // spawner, each window's `resources.authority`) is wired from it at
697 // construction, so there is no window in which, e.g., quick-open's
698 // `git ls-files` runs through a local spawner while the filesystem is
699 // already remote. Runtime authority transitions still go through the
700 // destructive `install_authority` restart (principle 7), which
701 // rebuilds the editor with the next authority via this same path.
702 let process_spawner = Arc::clone(&authority.process_spawner);
703
704 // Initialize Quick Open registry with all providers
705 let mut quick_open_registry = QuickOpenRegistry::new();
706 quick_open_registry.register(Box::new(FileProvider::new(
707 Arc::clone(&filesystem),
708 Arc::clone(&process_spawner),
709 tokio_runtime.as_ref().map(|rt| rt.handle().clone()),
710 Some(async_bridge.sender()),
711 )));
712 quick_open_registry.register(Box::new(CommandProvider::new(
713 Arc::clone(&command_registry),
714 Arc::clone(&keybindings),
715 )));
716 quick_open_registry.register(Box::new(BufferProvider::new()));
717 quick_open_registry.register(Box::new(GotoLineProvider::new()));
718
719 // Build shared theme cache for plugin access
720 let theme_cache = Arc::new(RwLock::new(theme_registry.to_json_map()));
721
722 t.phase("split_quickopen_authority");
723 // Initialize plugin manager (handles both enabled and disabled cases internally)
724 let plugin_manager = Arc::new(RwLock::new(PluginManager::new(
725 enable_plugins,
726 Arc::clone(&command_registry),
727 dir_context.clone(),
728 Arc::clone(&theme_cache),
729 )));
730 t.phase("PluginManager::new");
731
732 // Update the plugin state snapshot with working_dir BEFORE loading plugins
733 // This ensures plugins can call getCwd() correctly during initialization
734 #[cfg(feature = "plugins")]
735 if let Some(snapshot_handle) = plugin_manager.read().unwrap().state_snapshot_handle() {
736 let mut snapshot = snapshot_handle.write().unwrap();
737 snapshot.working_dir = working_dir.clone();
738 // Pre-populate keybinding labels for the static built-in
739 // keymap so `editor.getKeybindingLabel(action, context)`
740 // works for actions that aren't behind a plugin-defined
741 // buffer mode. Without this, a plugin asking
742 // `getKeybindingLabel("cycle_live_grep_provider",
743 // "prompt")` gets null even though Alt+P is bound, and
744 // ends up hardcoding the key in its UI.
745 populate_builtin_keybinding_labels(&mut snapshot, &keybindings);
746 // Seed the snapshot's `config` view with the resolved
747 // initial config so plugins reading
748 // `editor.getPluginConfig()` (and the lower-level
749 // `defineConfigX` snapshot-lookups) see user-set values
750 // on their very first call. Without this seed the
751 // synchronous test path runs plugin scripts BEFORE the
752 // first `update_plugin_state_snapshot` tick, so a
753 // preset `plugins.<name>.settings.<field>` is invisible
754 // to the plugin until much later — defeating any
755 // "react to user config at startup" pattern (e.g.
756 // vi_mode's `autoStart`).
757 if let Ok(json) = serde_json::to_value(&config) {
758 snapshot.config = std::sync::Arc::new(json);
759 }
760 }
761
762 // Load TypeScript plugins from multiple directories:
763 // 1. Next to the executable (for cargo-dist installations)
764 // 1. Embedded plugins (compiled into the binary via the
765 // embed-plugins feature, default on for every shipped build).
766 // 2. User plugins directory (~/.config/fresh/plugins).
767 // 3. Package manager installed plugins (~/.config/fresh/plugins/packages/*).
768 // No working-directory or exe-dir lookup: a user project with a folder
769 // named `plugins/` (a Vite/Rollup project, a Hugo site) is not a Fresh
770 // plugin source, and packagers no longer ship plugins/ alongside the
771 // binary now that the bundled set is fully embedded.
772 // Plugin schemas populated lazily by plugins calling
773 // `editor.definePluginConfig(...)` at load time. See
774 // `handle_register_plugin_config_schema`.
775 let plugin_schemas: HashMap<String, serde_json::Value> = HashMap::new();
776 if plugin_manager.read().unwrap().is_active() {
777 let mut plugin_dirs: Vec<std::path::PathBuf> = vec![];
778
779 // Embedded plugins. `enable_embedded_plugins` lets tests opt out so
780 // they get exactly the plugin set they pre-populated under
781 // `<config_dir>/plugins/`, without the bundled set leaking in.
782 #[cfg(feature = "embed-plugins")]
783 if enable_embedded_plugins && plugin_dirs.is_empty() {
784 if let Some(embedded_dir) =
785 crate::services::plugins::embedded::get_embedded_plugins_dir()
786 {
787 tracing::info!("Using embedded plugins from: {:?}", embedded_dir);
788 plugin_dirs.push(embedded_dir.clone());
789 }
790 }
791
792 // Always check user config plugins directory (~/.config/fresh/plugins)
793 let user_plugins_dir = dir_context.config_dir.join("plugins");
794 if user_plugins_dir.exists() && !plugin_dirs.contains(&user_plugins_dir) {
795 tracing::info!("Found user plugins directory: {:?}", user_plugins_dir);
796 plugin_dirs.push(user_plugins_dir.clone());
797 }
798
799 // Check for package manager installed plugins (~/.config/fresh/plugins/packages/*)
800 let packages_dir = dir_context.config_dir.join("plugins").join("packages");
801 if packages_dir.exists() {
802 if let Ok(entries) = std::fs::read_dir(&packages_dir) {
803 for entry in entries.flatten() {
804 let path = entry.path();
805 // Skip hidden directories (like .index for registry cache)
806 if path.is_dir() {
807 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
808 if !name.starts_with('.') {
809 tracing::info!("Found package manager plugin: {:?}", path);
810 plugin_dirs.push(path);
811 }
812 }
813 }
814 }
815 }
816 }
817
818 // Add bundle plugin directories from package scan
819 for dir in &scan_result.bundle_plugin_dirs {
820 tracing::info!("Found bundle plugin directory: {:?}", dir);
821 plugin_dirs.push(dir.clone());
822 }
823
824 if plugin_dirs.is_empty() {
825 tracing::debug!(
826 "No plugins directory found next to executable or in working dir: {:?}",
827 working_dir
828 );
829 }
830
831 if defer_plugin_load {
832 // Async startup path: hand each dir + a trailing
833 // ListPlugins request to the plugin thread now, return
834 // before they finish, and let a forwarder thread
835 // translate the responses into AsyncMessages that the
836 // main loop applies via `process_async_messages`. The
837 // plugin thread is FIFO, so submitting in this exact
838 // order guarantees declarations cover only the startup
839 // batch — init.ts and lifecycle hooks queue *after*
840 // ListPlugins from main.rs after construction returns,
841 // matching the original blocking behaviour.
842 #[cfg(feature = "plugins")]
843 {
844 let bridge = &async_bridge;
845 let mut dir_receivers: Vec<(
846 std::path::PathBuf,
847 fresh_plugin_runtime::thread::oneshot::Receiver<
848 fresh_plugin_runtime::thread::PluginsDirLoadResult,
849 >,
850 )> = Vec::with_capacity(plugin_dirs.len());
851 for plugin_dir in &plugin_dirs {
852 tracing::info!(
853 "Submitting async TypeScript plugin load for: {:?}",
854 plugin_dir
855 );
856 if let Some(rx) = plugin_manager
857 .read()
858 .unwrap()
859 .load_plugins_from_dir_with_config_request(plugin_dir, &config.plugins)
860 {
861 dir_receivers.push((plugin_dir.clone(), rx));
862 }
863 }
864 let declarations_rx = if !dir_receivers.is_empty() {
865 plugin_manager.read().unwrap().list_plugins_request()
866 } else {
867 None
868 };
869 if !dir_receivers.is_empty() {
870 let sender = bridge.sender();
871 std::thread::Builder::new()
872 .name("plugin-load-forwarder".to_string())
873 .spawn(move || {
874 for (dir, rx) in dir_receivers {
875 let load_start = std::time::Instant::now();
876 match rx.recv() {
877 Ok((errors, discovered_plugins)) => {
878 tracing::info!(
879 "Loaded TypeScript plugins from {:?} in {:?}",
880 dir,
881 load_start.elapsed()
882 );
883 drop(sender.send(
884 crate::services::async_bridge::AsyncMessage::PluginsDirLoaded {
885 dir,
886 errors,
887 discovered_plugins,
888 },
889 ));
890 }
891 Err(e) => {
892 tracing::warn!(
893 "plugin-load-forwarder: dir {:?} recv failed: {}",
894 dir,
895 e
896 );
897 }
898 }
899 }
900 if let Some(rx) = declarations_rx {
901 match rx.recv() {
902 Ok(plugin_infos) => {
903 let declarations: Vec<(String, String)> = plugin_infos
904 .into_iter()
905 .filter_map(|info| {
906 info.declarations.map(|d| (info.name, d))
907 })
908 .collect();
909 drop(sender.send(
910 crate::services::async_bridge::AsyncMessage::PluginDeclarationsReady {
911 declarations,
912 },
913 ));
914 }
915 Err(e) => {
916 tracing::warn!(
917 "plugin-load-forwarder: list_plugins recv failed: {}",
918 e
919 );
920 }
921 }
922 }
923 })
924 .ok();
925 }
926 }
927 } else {
928 // Synchronous (legacy / test) path. Used by `for_test`,
929 // server, GUI: every other code path that wants the
930 // editor fully constructed before the constructor
931 // returns.
932 for plugin_dir in plugin_dirs {
933 tracing::info!("Loading TypeScript plugins from: {:?}", plugin_dir);
934 let load_start = std::time::Instant::now();
935 let (errors, discovered_plugins) = plugin_manager
936 .read()
937 .unwrap()
938 .load_plugins_from_dir_with_config(&plugin_dir, &config.plugins);
939 tracing::info!(
940 "Loaded TypeScript plugins from {:?} in {:?}",
941 plugin_dir,
942 load_start.elapsed()
943 );
944
945 // Merge discovered plugins into config
946 // discovered_plugins already contains the merged config (saved enabled state + discovered path)
947 for (name, plugin_config) in discovered_plugins {
948 config.plugins.insert(name, plugin_config);
949 }
950
951 if !errors.is_empty() {
952 for err in &errors {
953 tracing::error!("TypeScript plugin load error: {}", err);
954 }
955 // In debug/test builds, panic to surface plugin loading errors
956 #[cfg(debug_assertions)]
957 panic!(
958 "TypeScript plugin loading failed with {} error(s): {}",
959 errors.len(),
960 errors.join("; ")
961 );
962 }
963 }
964
965 // Collect `.d.ts` emits from every loaded plugin into a
966 // single aggregate under `<config_dir>/types/plugins.d.ts`.
967 // This is what makes `getPluginApi("foo")` typed in the
968 // user's init.ts without a hand-written cast — each plugin
969 // that uses `declare global { interface FreshPluginRegistry }`
970 // contributes its augmentation, and init.ts's tsconfig
971 // picks the aggregate up via `files`.
972 let declarations = plugin_manager.read().unwrap().plugin_declarations();
973 crate::init_script::write_plugin_declarations(
974 &dir_context.config_dir,
975 &declarations,
976 );
977 }
978 }
979
980 t.phase("plugin_loading");
981 // Extract config values before moving config into the struct
982 let recovery_enabled = config.editor.recovery_enabled;
983 let check_for_updates = config.check_for_updates;
984
985 // Start periodic update checker if enabled (also sends daily telemetry)
986 let update_checker = if check_for_updates {
987 tracing::debug!("Update checking enabled, starting periodic checker");
988 Some(
989 crate::services::release_checker::start_periodic_update_check(
990 crate::services::release_checker::DEFAULT_RELEASES_URL,
991 time_source.clone(),
992 dir_context.data_dir.clone(),
993 ),
994 )
995 } else {
996 tracing::debug!("Update checking disabled by config");
997 None
998 };
999
1000 // Cache raw user config at startup (to avoid re-reading file every frame)
1001 let user_config_raw = Config::read_user_config_raw(&working_dir);
1002
1003 // Wrap config in Arc and pre-seed the snapshot mirror + JSON cache.
1004 // Doing this at construction means the strong count of the live
1005 // `config` Arc starts at 2 and stays there: every `Arc::make_mut`
1006 // call on `config` is forced to CoW, so no mutation path (direct or
1007 // via `config_mut()`) can leave `config_cached_json` referring to
1008 // stale memory.
1009 let config_arc = Arc::new(config);
1010 let config_cached_json =
1011 Arc::new(serde_json::to_value(&*config_arc).unwrap_or(serde_json::Value::Null));
1012 let config_snapshot_anchor = Arc::clone(&config_arc);
1013
1014 // The buffer-id allocator starts at the same value as
1015 // `next_buffer_id`. Both are kept in sync by every allocation
1016 // path (`Editor::alloc_buffer_id` advances both); the allocator
1017 // is what gets cloned into every `Window` so handlers on
1018 // `impl Window` can mint ids without an `Editor` reference.
1019 let buffer_id_alloc = crate::app::window_resources::BufferIdAllocator::new(2);
1020
1021 // The local-host filesystem handle. Hoisted here (rather than
1022 // constructed inline in the `Editor` literal below) so the
1023 // base window's `WindowResources` and the editor share the same
1024 // `Arc` from the start.
1025 let local_filesystem: Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> =
1026 Arc::new(crate::model::filesystem::StdFileSystem);
1027
1028 // Hot-exit recovery service, shared (Arc<Mutex>) into every
1029 // `Window` via `WindowResources` so per-window restore/auto-save
1030 // can reach it without an active-window flip.
1031 let recovery_service = {
1032 let recovery_config = RecoveryConfig {
1033 enabled: recovery_enabled,
1034 ..RecoveryConfig::default()
1035 };
1036 // Default to a CWD-scoped recovery directory so each working
1037 // directory keeps its own hot-exit recovery files. If this
1038 // editor is later promoted to session mode, `set_session_name`
1039 // re-creates the service with `RecoveryScope::Session`.
1040 // Issue #1550: without per-CWD scoping, opening Fresh in a
1041 // second folder would clobber the first folder's unsaved
1042 // unnamed buffers on shutdown.
1043 let scope = crate::services::recovery::RecoveryScope::Standalone {
1044 working_dir: working_dir.clone(),
1045 };
1046 std::sync::Arc::new(std::sync::Mutex::new(RecoveryService::with_scope(
1047 recovery_config,
1048 &dir_context.recovery_dir(),
1049 &scope,
1050 )))
1051 };
1052
1053 // Build the resource bundle every `Window` gets a clone of. The
1054 // base window receives one clone here; subsequent windows
1055 // (created via `Editor::create_window_at` or first-dive seeding
1056 // in `set_active_window`) reach back to `Editor::window_resources()`
1057 // for an equivalent bundle.
1058 let base_resources = crate::app::window_resources::WindowResources {
1059 config: Arc::clone(&config_arc),
1060 grammar_registry: Arc::clone(&grammar_registry),
1061 theme_registry: Arc::clone(&theme_registry),
1062 theme_cache: Arc::clone(&theme_cache),
1063 keybindings: Arc::clone(&keybindings),
1064 command_registry: Arc::clone(&command_registry),
1065 fs_manager: Arc::clone(&fs_manager),
1066 local_filesystem: Arc::clone(&local_filesystem),
1067 buffer_id_alloc: buffer_id_alloc.clone(),
1068 time_source: Arc::clone(&time_source),
1069 dir_context: dir_context.clone(),
1070 tokio_runtime: tokio_runtime.clone(),
1071 async_bridge: Some(async_bridge.clone()),
1072 plugin_manager: Arc::clone(&plugin_manager),
1073 theme: Arc::clone(&theme),
1074 event_broadcaster: event_broadcaster.clone(),
1075 recovery_service: Arc::clone(&recovery_service),
1076 };
1077
1078 // Build the active window — the one that holds the seed
1079 // buffer, the SplitManager, the LSP, and the
1080 // already-configured per-window bridge. Its label / root /
1081 // plugin state come from the persisted session we chose to
1082 // reopen (the last-used one for this cwd). When there was none
1083 // we boot a clean base: empty label, cwd root, no inherited
1084 // state. We deliberately key off the *picked* window, not a
1085 // lookup by `active_window_id` — a clean base reuses id 1, and
1086 // a stale persisted id-1 window (a different project's old
1087 // base) must not lend its label/root/state to it.
1088 let (active_label, active_root, active_plugin_state, active_authority_spec) = picked_active
1089 .map(|w| {
1090 (
1091 w.label.clone(),
1092 w.root.clone(),
1093 w.plugin_state.clone(),
1094 w.authority_spec.clone(),
1095 )
1096 })
1097 .unwrap_or_else(|| {
1098 (
1099 String::new(),
1100 working_dir.clone(),
1101 HashMap::new(),
1102 crate::services::authority::SessionAuthoritySpec::Local,
1103 )
1104 });
1105
1106 // The active window owns the editor's boot authority outright — moved
1107 // in, not cloned (there is no editor-wide copy).
1108 let mut active_win = crate::app::window::Window::new(
1109 active_window_id,
1110 active_label,
1111 active_root,
1112 authority,
1113 base_resources,
1114 );
1115 // Seed the window's terminal dimensions from the editor's
1116 // initial size — `Window::new` defaults to 80x24, which is
1117 // wrong for any harness that constructs the editor at a
1118 // different size (issue surfaces in
1119 // test_hidden_terminal_resyncs_pty_size_when_revealed).
1120 active_win.terminal_width = width;
1121 active_win.terminal_height = height;
1122 // Install the initial split layout. The LSP manager and per-
1123 // window bridge were already built by `Window::new` (rooted at
1124 // this window's root, wired together), so there's nothing to
1125 // hand off here — every window owns its manager by construction.
1126 active_win.buffers = buffers;
1127 active_win
1128 .buffers
1129 .set_splits((split_manager, split_view_states));
1130 active_win.buffer_metadata = buffer_metadata;
1131 active_win.event_logs = event_logs;
1132 active_win.plugin_state = active_plugin_state;
1133 active_win.authority_spec = active_authority_spec;
1134 // Load prompt histories from disk for the active window.
1135 // Each window has its own prompt-history rings.
1136 for history_name in ["search", "replace", "goto_line"] {
1137 let path = dir_context.prompt_history_path(history_name);
1138 let history = crate::input::input_history::InputHistory::load_from_file(&path)
1139 .unwrap_or_else(|e| {
1140 tracing::warn!("Failed to load {} history: {}", history_name, e);
1141 crate::input::input_history::InputHistory::new()
1142 });
1143 active_win
1144 .prompt_histories
1145 .insert(history_name.to_string(), history);
1146 }
1147
1148 // Build the inert shells for every other persisted window.
1149 // Their `splits` stays `None`; first dive into them re-warms
1150 // exactly like a freshly created window.
1151 // Background (restored, non-active) windows are distinct projects
1152 // and do NOT inherit the active session's backend: when this
1153 // construction is an `install_authority` restart (the editor is
1154 // rebuilt with a container/SSH/k8s `authority` re-rooted at the
1155 // active project), fanning that authority onto every restored shell
1156 // is exactly the bug where switching to another project via the
1157 // Orchestrator dock kept acting through the devcontainer. Each shell
1158 // gets its own local authority (sharing trust + env) and a matching
1159 // local `fs_manager` so its file explorer reads the host, not the
1160 // active session's remote/container backend. The active window keeps
1161 // `authority` (wired into `base_resources` above). Each shell's
1162 // authority — with its **own** per-session trust scoped to its root —
1163 // is built in the loop below so trusting the active project never
1164 // raises another session's trust level.
1165 let background_fs_manager = Arc::new(FsManager::new(Arc::new(
1166 crate::model::filesystem::StdFileSystem,
1167 )));
1168 let mut windows = HashMap::new();
1169 if let Some(ref env) = persisted_env {
1170 // The active window came from a real pick when `picked_active`
1171 // is `Some` — its persisted entry must NOT also become a shell.
1172 // When the pick found nothing we synthesized a clean base at
1173 // `WindowId(1)` (the base is always id 1); a global
1174 // `windows.json` may already hold a *different* project's id-1
1175 // base, which would collide. Re-id that collider onto a fresh
1176 // id so it survives as an inactive shell instead of being
1177 // shadowed/dropped (issue #2056 cross-project case).
1178 let active_came_from_pick = picked_active.is_some();
1179 let active_root_key =
1180 crate::app::orchestrator_persistence::canonical_key(&active_win.root);
1181 let mut next_fresh_id = env
1182 .next_id
1183 .max(env.windows.iter().map(|w| w.id).max().unwrap_or(0) + 1)
1184 .max(active_window_id.0 + 1);
1185 for ps in &env.windows {
1186 if active_came_from_pick && ps.id == active_window_id.0 {
1187 continue;
1188 }
1189 // One session per directory: never seed a shell that
1190 // resolves to the active window's own directory (the
1191 // clean-base case where the cwd has a stale persisted
1192 // window the pick didn't claim).
1193 if crate::app::orchestrator_persistence::canonical_key(&ps.root) == active_root_key
1194 {
1195 continue;
1196 }
1197 let id = if ps.id == active_window_id.0 {
1198 let fresh = fresh_core::WindowId(next_fresh_id);
1199 next_fresh_id += 1;
1200 fresh
1201 } else {
1202 fresh_core::WindowId(ps.id)
1203 };
1204 // This shell's own local authority, gated by its own
1205 // per-session trust + env (scoped to its root + project store)
1206 // — never a clone of the active session's handles.
1207 let shell_authority = crate::services::authority::Authority::local_scoped(
1208 crate::services::authority::SessionScope::for_root(
1209 &ps.root,
1210 &dir_context.project_state_dir(&ps.root),
1211 ),
1212 );
1213 let resources = crate::app::window_resources::WindowResources {
1214 config: Arc::clone(&config_arc),
1215 grammar_registry: Arc::clone(&grammar_registry),
1216 theme_registry: Arc::clone(&theme_registry),
1217 theme_cache: Arc::clone(&theme_cache),
1218 keybindings: Arc::clone(&keybindings),
1219 command_registry: Arc::clone(&command_registry),
1220 fs_manager: Arc::clone(&background_fs_manager),
1221 local_filesystem: Arc::clone(&local_filesystem),
1222 buffer_id_alloc: buffer_id_alloc.clone(),
1223 time_source: Arc::clone(&time_source),
1224 dir_context: dir_context.clone(),
1225 tokio_runtime: tokio_runtime.clone(),
1226 async_bridge: Some(async_bridge.clone()),
1227 plugin_manager: Arc::clone(&plugin_manager),
1228 theme: Arc::clone(&theme),
1229 event_broadcaster: event_broadcaster.clone(),
1230 recovery_service: Arc::clone(&recovery_service),
1231 };
1232 let mut shell = crate::app::window::Window::new(
1233 id,
1234 ps.label.clone(),
1235 ps.root.clone(),
1236 shell_authority,
1237 resources,
1238 );
1239 shell.terminal_width = width;
1240 shell.terminal_height = height;
1241 shell.plugin_state = ps.plugin_state.clone();
1242 // Carry the session's backend spec so an unmaterialized
1243 // background remote session keeps its identity (and a later
1244 // save doesn't clobber it back to local). Its live authority
1245 // stays the local placeholder until reconnect — i.e. dormant.
1246 shell.authority_spec = ps.authority_spec.clone();
1247 windows.insert(id, shell);
1248 }
1249 }
1250 windows.insert(active_window_id, active_win);
1251
1252 // Allocate next window ids past every persisted entry and
1253 // past our active id, so `createWindow` after restart never
1254 // collides with an id the user might still see in plugin
1255 // state. Falls back to 2 (the post-base-window default)
1256 // when there's no persistence.
1257 let max_existing = windows.keys().map(|k| k.0).max().unwrap_or(0);
1258 let next_window_id = persisted_env
1259 .as_ref()
1260 .map(|env| env.next_id.max(max_existing + 1))
1261 .unwrap_or(2);
1262
1263 let key_translator = crate::input::key_translator::KeyTranslator::load_from_config_dir(
1264 &dir_context.config_dir,
1265 )
1266 .unwrap_or_default();
1267
1268 let pending_grammars = scan_result
1269 .additional_grammars
1270 .iter()
1271 .map(|g| PendingGrammar {
1272 language: g.language.clone(),
1273 grammar_path: g.path.to_string_lossy().to_string(),
1274 extensions: g.extensions.clone(),
1275 })
1276 .collect();
1277
1278 let parts = EditorParts {
1279 config: config_arc,
1280 config_snapshot_anchor,
1281 config_cached_json,
1282 user_config_raw: Arc::new(user_config_raw),
1283 dir_context: dir_context.clone(),
1284 theme,
1285 theme_registry,
1286 theme_cache,
1287 grammar_registry,
1288 pending_grammars,
1289 needs_full_grammar_build: true,
1290 keybindings,
1291 buffer_id_alloc: buffer_id_alloc.clone(),
1292 next_buffer_id: 2,
1293 terminal_width: width,
1294 terminal_height: height,
1295 color_capability,
1296 tokio_runtime,
1297 async_bridge,
1298 local_filesystem: Arc::clone(&local_filesystem),
1299 windows,
1300 active_window: active_window_id,
1301 next_window_id,
1302 command_registry,
1303 quick_open_registry,
1304 plugin_manager,
1305 recovery_service,
1306 key_translator,
1307 update_checker,
1308 time_source: time_source.clone(),
1309 plugin_global_state,
1310 plugin_schemas,
1311 event_broadcaster: event_broadcaster.clone(),
1312 };
1313
1314 let mut editor = Editor::from_parts(parts);
1315
1316 t.phase("editor_struct_assembly");
1317 // Apply clipboard configuration
1318 editor.clipboard.apply_config(&editor.config.clipboard);
1319
1320 // Seed splits/buffers for every persisted inactive window so they
1321 // render in preview surfaces (Orchestrator's WindowEmbed) before the
1322 // user first dives in. Without this, restored windows have
1323 // `splits == None` and paint blank in the preview pane. We also
1324 // catch the (rarer) inverse where splits is set but the buffer
1325 // map is empty — that combo is what hit the historic
1326 // "active buffer must be present" panic in render.
1327 let needs_seed: Vec<fresh_core::WindowId> = editor
1328 .windows
1329 .iter()
1330 .filter(|(_, s)| s.buffers.splits().is_none() || s.buffers.len() == 0)
1331 .map(|(id, _)| *id)
1332 .collect();
1333 for id in needs_seed {
1334 if let Some((buf, state, metadata, event_log, mgr, vs)) =
1335 editor.build_fresh_layout_if_needed(id)
1336 {
1337 if let Some(s) = editor.windows.get_mut(&id) {
1338 s.buffers.set_splits((mgr, vs));
1339 s.buffers.insert(buf, state);
1340 s.buffer_metadata.insert(buf, metadata);
1341 s.event_logs.insert(buf, event_log);
1342 }
1343 }
1344 }
1345
1346 // Lazy materialization: every non-active window keeps only its
1347 // empty seed layout for now and is restored from disk on first
1348 // dive/preview (see `materialize_window`). Only the foreground
1349 // (CLI-dir) window is restored eagerly, by the caller's
1350 // `try_restore_workspace`.
1351 editor.materialize_pending = editor
1352 .windows
1353 .keys()
1354 .copied()
1355 .filter(|id| *id != editor.active_window)
1356 .collect();
1357
1358 #[cfg(feature = "plugins")]
1359 {
1360 editor.update_plugin_state_snapshot();
1361 if editor.plugin_manager.read().unwrap().is_active() {
1362 editor.plugin_manager.read().unwrap().run_hook(
1363 "editor_initialized",
1364 crate::services::plugins::hooks::HookArgs::EditorInitialized {},
1365 );
1366 }
1367 }
1368 t.phase("post_struct_hooks");
1369 t.finish();
1370 Ok(editor)
1371 }
1372
1373 /// Get a reference to the event broadcaster
1374 pub fn event_broadcaster(&self) -> &crate::model::control_event::EventBroadcaster {
1375 &self.event_broadcaster
1376 }
1377
1378 /// Spawn a background thread to build the full grammar registry
1379 /// (embedded grammars, user grammars, language packs, and any plugin-registered grammars).
1380 /// Called on the first event-loop tick (via `flush_pending_grammars`) so that
1381 /// plugin grammars registered during init are included in a single build.
1382 pub(super) fn start_background_grammar_build(
1383 &mut self,
1384 additional: Vec<crate::primitives::grammar::GrammarSpec>,
1385 callback_ids: Vec<fresh_core::api::JsCallbackId>,
1386 ) {
1387 let Some(bridge) = &self.async_bridge else {
1388 return;
1389 };
1390 self.grammar_build_in_progress = true;
1391 let sender = bridge.sender();
1392 let config_dir = self.dir_context.config_dir.clone();
1393 tracing::info!(
1394 "Spawning background grammar build thread ({} plugin grammars)...",
1395 additional.len()
1396 );
1397 std::thread::Builder::new()
1398 .name("grammar-build".to_string())
1399 .spawn(move || {
1400 tracing::info!("[grammar-build] Thread started");
1401 let start = std::time::Instant::now();
1402 let registry = if additional.is_empty() {
1403 crate::primitives::grammar::GrammarRegistry::for_editor(config_dir)
1404 } else {
1405 crate::primitives::grammar::GrammarRegistry::for_editor_with_additional(
1406 config_dir,
1407 &additional,
1408 )
1409 };
1410 tracing::info!("[grammar-build] Complete in {:?}", start.elapsed());
1411 drop(sender.send(
1412 crate::services::async_bridge::AsyncMessage::GrammarRegistryBuilt {
1413 registry,
1414 callback_ids,
1415 },
1416 ));
1417 })
1418 .ok();
1419 }
1420
1421 // =========================================================================
1422 // init.ts / runtime-overlay surface (design docs §3–§6)
1423 // =========================================================================
1424
1425 /// Auto-load `~/.config/fresh/init.ts` if present, through the existing
1426 /// plugin pipeline under the stable name `crate::init_script::INIT_PLUGIN_NAME`.
1427 pub fn load_init_script(&mut self, enabled: bool) {
1428 use crate::init_script::{
1429 check, decide_load, describe, record_success, refresh_types_scaffolding, CheckSeverity,
1430 InitOutcome, LoadDecision,
1431 };
1432
1433 let config_dir = self.dir_context.config_dir.clone();
1434
1435 if enabled {
1436 // Refresh the types mirror from the embedded copy before anything
1437 // reads init.ts. Guarantees the declarations the user sees match
1438 // the running build — stale types would hide API drift.
1439 refresh_types_scaffolding(&config_dir);
1440
1441 // Re-check init.ts right after the refresh so drift between the
1442 // user's script and the current API surface (at least syntax-level
1443 // fallout like unterminated blocks from a botched rename) shows up
1444 // in the log immediately rather than only at eval time.
1445 let report = check(&config_dir);
1446 if !report.ok {
1447 for d in &report.diagnostics {
1448 let level = match d.severity {
1449 CheckSeverity::Error => "error",
1450 CheckSeverity::Warning => "warning",
1451 };
1452 tracing::warn!(
1453 "init.ts pre-load {level} at {}:{}: {}",
1454 d.line,
1455 d.column,
1456 d.message
1457 );
1458 }
1459 }
1460 }
1461
1462 let outcome = match decide_load(&config_dir, enabled) {
1463 LoadDecision::Skip(outcome) => outcome,
1464 LoadDecision::Load { source } => {
1465 if !self.plugin_manager.read().unwrap().is_active() {
1466 InitOutcome::Failed {
1467 message: "plugin runtime inactive (--no-plugins); init.ts cannot run"
1468 .into(),
1469 }
1470 } else {
1471 match self.plugin_manager.read().unwrap().load_plugin_from_source(
1472 &source,
1473 crate::init_script::INIT_PLUGIN_NAME,
1474 true,
1475 ) {
1476 Ok(()) => {
1477 record_success(&config_dir);
1478 InitOutcome::Loaded
1479 }
1480 Err(e) => InitOutcome::Failed {
1481 message: format!("{e}"),
1482 },
1483 }
1484 }
1485 }
1486 };
1487
1488 let summary = describe(&outcome);
1489 match outcome {
1490 InitOutcome::NotFound | InitOutcome::Disabled => tracing::debug!("{}", summary),
1491 InitOutcome::Loaded => tracing::info!("{}", summary),
1492 InitOutcome::CrashFused { .. } | InitOutcome::Failed { .. } => {
1493 tracing::warn!("{}", summary);
1494 self.set_status_message(summary);
1495 }
1496 }
1497 }
1498
1499 /// Non-blocking variant of [`Self::load_init_script`] for the TUI
1500 /// startup path. Does the synchronous pre-work (types scaffolding
1501 /// refresh, syntax check, fuse check), then either submits the
1502 /// `LoadPluginFromSource` request to the plugin thread and spawns a
1503 /// forwarder that translates the result into
1504 /// `AsyncMessage::PluginInitScriptLoaded`, or — for the `Skip(...)`
1505 /// outcomes — emits the message directly so the same async-dispatch
1506 /// handler logs and applies status. The request goes through the
1507 /// same FIFO channel as the startup plugin loads, so by the time the
1508 /// plugin thread evaluates init.ts every batch plugin has already
1509 /// finished — preserving the original load ordering.
1510 pub fn load_init_script_async(&mut self, enabled: bool) {
1511 use crate::init_script::{
1512 check, decide_load, refresh_types_scaffolding, CheckSeverity, InitOutcome, LoadDecision,
1513 };
1514 use crate::services::async_bridge::PluginInitScriptOutcome;
1515
1516 let config_dir = self.dir_context.config_dir.clone();
1517
1518 if enabled {
1519 refresh_types_scaffolding(&config_dir);
1520 let report = check(&config_dir);
1521 if !report.ok {
1522 for d in &report.diagnostics {
1523 let level = match d.severity {
1524 CheckSeverity::Error => "error",
1525 CheckSeverity::Warning => "warning",
1526 };
1527 tracing::warn!(
1528 "init.ts pre-load {level} at {}:{}: {}",
1529 d.line,
1530 d.column,
1531 d.message
1532 );
1533 }
1534 }
1535 }
1536
1537 let outcome_now: Option<PluginInitScriptOutcome> = match decide_load(&config_dir, enabled) {
1538 LoadDecision::Skip(outcome) => Some(match outcome {
1539 InitOutcome::NotFound => PluginInitScriptOutcome::NotFound,
1540 InitOutcome::Disabled => PluginInitScriptOutcome::Disabled,
1541 InitOutcome::CrashFused { failures } => {
1542 PluginInitScriptOutcome::CrashFused { failures }
1543 }
1544 // decide_load only returns these via Load; keep total to
1545 // satisfy the matcher.
1546 InitOutcome::Loaded => PluginInitScriptOutcome::Loaded,
1547 InitOutcome::Failed { message } => PluginInitScriptOutcome::Failed { message },
1548 }),
1549 LoadDecision::Load { source } => {
1550 if !self.plugin_manager.read().unwrap().is_active() {
1551 Some(PluginInitScriptOutcome::Failed {
1552 message: "plugin runtime inactive (--no-plugins); init.ts cannot run"
1553 .into(),
1554 })
1555 } else {
1556 self.spawn_init_script_forwarder(source);
1557 None
1558 }
1559 }
1560 };
1561
1562 if let Some(outcome) = outcome_now {
1563 // Skip / fused / inactive paths: emit through the bridge so
1564 // the same handler runs them as the success path. Falls back
1565 // to direct application if the bridge is missing (test).
1566 if let Some(bridge) = &self.async_bridge {
1567 drop(bridge.sender().send(
1568 crate::services::async_bridge::AsyncMessage::PluginInitScriptLoaded(outcome),
1569 ));
1570 } else {
1571 self.handle_plugin_init_script_loaded(outcome);
1572 }
1573 }
1574 }
1575
1576 #[cfg(feature = "plugins")]
1577 fn spawn_init_script_forwarder(&self, source: String) {
1578 let Some(bridge) = &self.async_bridge else {
1579 return;
1580 };
1581 let Some(rx) = self
1582 .plugin_manager
1583 .read()
1584 .unwrap()
1585 .load_plugin_from_source_request(&source, crate::init_script::INIT_PLUGIN_NAME, true)
1586 else {
1587 return;
1588 };
1589 let sender = bridge.sender();
1590 std::thread::Builder::new()
1591 .name("plugin-init-forwarder".to_string())
1592 .spawn(move || {
1593 let outcome = match rx.recv() {
1594 Ok(Ok(())) => crate::services::async_bridge::PluginInitScriptOutcome::Loaded,
1595 Ok(Err(e)) => crate::services::async_bridge::PluginInitScriptOutcome::Failed {
1596 message: format!("{e}"),
1597 },
1598 Err(e) => crate::services::async_bridge::PluginInitScriptOutcome::Failed {
1599 message: format!("plugin thread closed: {e}"),
1600 },
1601 };
1602 drop(sender.send(
1603 crate::services::async_bridge::AsyncMessage::PluginInitScriptLoaded(outcome),
1604 ));
1605 })
1606 .ok();
1607 }
1608
1609 #[cfg(not(feature = "plugins"))]
1610 fn spawn_init_script_forwarder(&self, _source: String) {}
1611
1612 /// Handle `setSetting(path, value)`. Fire-and-forget: patches Config
1613 /// directly via JSON round-trip. No overlay, no per-plugin tracking,
1614 /// no revert on unload — same model as Neovim/VS Code/Emacs/Sublime.
1615 pub fn handle_set_setting(&mut self, path: String, value: serde_json::Value) {
1616 let mut json = serde_json::to_value(&*self.config).unwrap_or_default();
1617 set_dot_path(&mut json, &path, value);
1618 match serde_json::from_value::<crate::config::Config>(json) {
1619 Ok(new_config) => {
1620 let old_theme = self.config.theme.clone();
1621 self.config = Arc::new(new_config);
1622 if old_theme != self.config.theme {
1623 if let Some(theme) = self.theme_registry.get_cloned(&self.config.theme) {
1624 *self.theme.write().unwrap() = theme;
1625 self.start_theme_transition_animation();
1626 }
1627 }
1628 *self.keybindings.write().unwrap() =
1629 crate::input::keybindings::KeybindingResolver::new(&self.config);
1630 self.clipboard.apply_config(&self.config.clipboard);
1631 {
1632 let cfg = self.config.editor.clone();
1633 let win = self.active_window_mut();
1634 win.menu_bar_visible = cfg.show_menu_bar;
1635 win.tab_bar_visible = cfg.show_tab_bar;
1636 win.status_bar_visible = cfg.show_status_bar;
1637 win.prompt_line_visible = cfg.show_prompt_line;
1638 }
1639 #[cfg(feature = "plugins")]
1640 self.update_plugin_state_snapshot();
1641 }
1642 Err(e) => {
1643 self.set_status_message(format!("setSetting({path}): {e}"));
1644 }
1645 }
1646 }
1647
1648 /// Append a single config field to a plugin's accumulated schema and
1649 /// pre-populate its default value. Each `defineConfigX(...)` call
1650 /// from the plugin's TS code fires one of these.
1651 ///
1652 /// On first call for a plugin we synthesise a fresh
1653 /// `{"type": "object", "properties": {}}` schema and grow it as more
1654 /// fields arrive. Re-registering the same `field_name` overwrites
1655 /// the previous definition (which is what we want on plugin
1656 /// reload — plugins re-run their `defineConfigX` calls).
1657 pub fn handle_add_plugin_config_field(
1658 &mut self,
1659 plugin_name: String,
1660 field_name: String,
1661 field_schema: serde_json::Value,
1662 ) {
1663 tracing::trace!(
1664 "Registering plugin config field: {}.{}",
1665 plugin_name,
1666 field_name
1667 );
1668 // Merge the new field into the existing accumulated schema (or a
1669 // fresh one) and run the same strict validation as a bulk-register.
1670 let updated_schema = {
1671 let schemas = self.plugin_schemas.read().ok();
1672 let existing = schemas.as_ref().and_then(|m| m.get(&plugin_name)).cloned();
1673 let mut schema = existing.unwrap_or_else(|| {
1674 serde_json::json!({
1675 "type": "object",
1676 "properties": {},
1677 })
1678 });
1679 if let Some(props) = schema
1680 .as_object_mut()
1681 .and_then(|o| o.get_mut("properties"))
1682 .and_then(|p| p.as_object_mut())
1683 {
1684 props.insert(field_name.clone(), field_schema.clone());
1685 }
1686 schema
1687 };
1688
1689 if let Err(msg) = crate::plugin_schemas::validate_plugin_schema(&updated_schema) {
1690 // Field passed JS-side validation but somehow broke the full
1691 // schema — log and skip so we don't poison the registry.
1692 self.set_status_message(format!(
1693 "defineConfig({}.{}): {}",
1694 plugin_name, field_name, msg
1695 ));
1696 return;
1697 }
1698
1699 // Pre-populate the default for THIS field only.
1700 if let Some(default) = field_schema.get("default").cloned() {
1701 let cfg = std::sync::Arc::make_mut(&mut self.config);
1702 let entry = cfg.plugins.entry(plugin_name.clone()).or_default();
1703 let settings_obj = match &mut entry.settings {
1704 serde_json::Value::Object(_) => &mut entry.settings,
1705 slot => {
1706 *slot = serde_json::Value::Object(Default::default());
1707 slot
1708 }
1709 };
1710 if let serde_json::Value::Object(map) = settings_obj {
1711 map.entry(field_name.clone()).or_insert(default);
1712 }
1713 }
1714
1715 if let Ok(mut schemas) = self.plugin_schemas.write() {
1716 schemas.insert(plugin_name, updated_schema);
1717 }
1718
1719 #[cfg(feature = "plugins")]
1720 self.update_plugin_state_snapshot();
1721 }
1722
1723 /// Apply the result of one async startup-batch directory load.
1724 /// Mirrors the per-iteration body of the legacy synchronous loop in
1725 /// `with_options`: merge discovered plugins into config, log errors,
1726 /// and panic in debug builds (the legacy behaviour).
1727 pub(crate) fn handle_plugins_dir_loaded(
1728 &mut self,
1729 dir: std::path::PathBuf,
1730 errors: Vec<String>,
1731 discovered_plugins: std::collections::HashMap<String, fresh_core::config::PluginConfig>,
1732 ) {
1733 if !discovered_plugins.is_empty() {
1734 let cfg = std::sync::Arc::make_mut(&mut self.config);
1735 for (name, plugin_config) in discovered_plugins {
1736 cfg.plugins.insert(name, plugin_config);
1737 }
1738 }
1739 if !errors.is_empty() {
1740 for err in &errors {
1741 tracing::error!("TypeScript plugin load error: {}", err);
1742 }
1743 #[cfg(debug_assertions)]
1744 panic!(
1745 "TypeScript plugin loading failed for {:?} with {} error(s): {}",
1746 dir,
1747 errors.len(),
1748 errors.join("; ")
1749 );
1750 #[cfg(not(debug_assertions))]
1751 {
1752 let _ = dir;
1753 }
1754 }
1755 }
1756
1757 /// Apply the declarations harvested at the end of the async startup
1758 /// batch. Mirrors the synchronous `plugin_declarations` +
1759 /// `write_plugin_declarations` pair in `with_options`.
1760 pub(crate) fn handle_plugin_declarations_ready(&self, declarations: Vec<(String, String)>) {
1761 crate::init_script::write_plugin_declarations(&self.dir_context.config_dir, &declarations);
1762 }
1763
1764 /// Apply the result of the async `init.ts` load. Mirrors the trailing
1765 /// `match outcome { ... }` block of the legacy synchronous
1766 /// `load_init_script`.
1767 pub(crate) fn handle_plugin_init_script_loaded(
1768 &mut self,
1769 outcome: crate::services::async_bridge::PluginInitScriptOutcome,
1770 ) {
1771 use crate::init_script::{describe, record_success, InitOutcome};
1772 use crate::services::async_bridge::PluginInitScriptOutcome as O;
1773 let outcome = match outcome {
1774 O::NotFound => InitOutcome::NotFound,
1775 O::Disabled => InitOutcome::Disabled,
1776 O::CrashFused { failures } => InitOutcome::CrashFused { failures },
1777 O::Loaded => {
1778 record_success(&self.dir_context.config_dir);
1779 InitOutcome::Loaded
1780 }
1781 O::Failed { message } => InitOutcome::Failed { message },
1782 };
1783 let summary = describe(&outcome);
1784 match outcome {
1785 InitOutcome::NotFound | InitOutcome::Disabled => tracing::debug!("{}", summary),
1786 InitOutcome::Loaded => tracing::info!("{}", summary),
1787 InitOutcome::CrashFused { .. } | InitOutcome::Failed { .. } => {
1788 tracing::warn!("{}", summary);
1789 self.set_status_message(summary);
1790 }
1791 }
1792 }
1793
1794 /// Fire the `plugins_loaded` hook (design M2, §3.3 phase 2).
1795 pub fn fire_plugins_loaded_hook(&self) {
1796 #[cfg(feature = "plugins")]
1797 if self.plugin_manager.read().unwrap().is_active() {
1798 self.plugin_manager.read().unwrap().run_hook(
1799 "plugins_loaded",
1800 crate::services::plugins::hooks::HookArgs::PluginsLoaded {},
1801 );
1802 }
1803 }
1804
1805 /// Fire the `ready` hook (design M2, §3.3 phase 3).
1806 pub fn fire_ready_hook(&self) {
1807 #[cfg(feature = "plugins")]
1808 if self.plugin_manager.read().unwrap().is_active() {
1809 self.plugin_manager
1810 .read()
1811 .unwrap()
1812 .run_hook("ready", crate::services::plugins::hooks::HookArgs::Ready {});
1813 }
1814 }
1815
1816 /// Test-only accessor for the current effective config.
1817 #[doc(hidden)]
1818 pub fn config_for_tests(&self) -> &crate::config::Config {
1819 &self.config
1820 }
1821
1822 /// Test-only shim that dispatches an action through the normal path.
1823 #[doc(hidden)]
1824 pub fn dispatch_action_for_tests(&mut self, action: crate::input::keybindings::Action) {
1825 if let Err(e) = self.handle_action(action) {
1826 tracing::warn!("dispatch_action_for_tests: {e}");
1827 }
1828 }
1829
1830 /// Test-only accessor for the Live Grep Resume cache (issue #1796).
1831 #[doc(hidden)]
1832 pub fn live_grep_last_state_for_tests(
1833 &self,
1834 ) -> Option<&crate::services::live_grep_state::LiveGrepLastState> {
1835 self.active_window().live_grep_last_state.as_ref()
1836 }
1837
1838 /// Test-only setter for the Live Grep Resume cache.
1839 #[doc(hidden)]
1840 pub fn set_live_grep_last_state_for_tests(
1841 &mut self,
1842 state: Option<crate::services::live_grep_state::LiveGrepLastState>,
1843 ) {
1844 self.active_window_mut().live_grep_last_state = state;
1845 }
1846
1847 /// Test-only accessor for the split tree, so layout-shape
1848 /// regression tests can assert on the structure directly.
1849 #[doc(hidden)]
1850 pub fn split_manager_for_tests(&self) -> &crate::view::split::SplitManager {
1851 self.windows
1852 .get(&self.active_window)
1853 .and_then(|w| w.buffers.splits())
1854 .map(|(mgr, _)| mgr)
1855 .expect("active window must have a populated split layout")
1856 }
1857
1858 /// Test-only accessor for a leaf's `SplitViewState`, so tab-list
1859 /// regression tests can verify which buffers are open in a given
1860 /// pane (the dock should only contain the buffer the user
1861 /// actually asked for, not phantom placeholders).
1862 #[doc(hidden)]
1863 pub fn split_view_state_for_tests(
1864 &self,
1865 leaf: crate::model::event::LeafId,
1866 ) -> Option<&crate::view::split::SplitViewState> {
1867 self.windows
1868 .get(&self.active_window)
1869 .and_then(|w| w.buffers.splits())
1870 .map(|(_, vs)| vs)
1871 .expect("active window must have a populated split layout")
1872 .get(&leaf)
1873 }
1874
1875 /// Refresh the plugin-readable keybinding-label snapshot from
1876 /// the current keymap. Call this whenever a plugin is about to
1877 /// surface key hints in its UI (overlay headers, tooltips,
1878 /// menus) so the user's most-recent rebinds are reflected.
1879 ///
1880 /// Cheap — walks every typed `Action` × ~9 contexts; runs in
1881 /// well under a millisecond on this hardware. Cheaper than
1882 /// adding refresh hooks to every keymap-mutation site.
1883 #[cfg(feature = "plugins")]
1884 pub(crate) fn refresh_keybinding_labels_snapshot(&self) {
1885 if let Some(snapshot_handle) = self.plugin_manager.read().unwrap().state_snapshot_handle() {
1886 if let Ok(mut snapshot) = snapshot_handle.write() {
1887 populate_builtin_keybinding_labels(&mut snapshot, &self.keybindings);
1888 }
1889 }
1890 }
1891}
1892
1893/// Walk every typed `Action` and the contexts most relevant to UI
1894/// labels (`Normal`, `Prompt`, `Popup`, `FileExplorer`,
1895/// `CompositeBuffer`, `Settings`, `Terminal`), and populate the
1896/// snapshot's `keybinding_labels` map with `<action>\0<context>` →
1897/// formatted label (e.g. `"cycle_live_grep_provider\0prompt"` →
1898/// `"Alt+P"`). The plugin-side `editor.getKeybindingLabel(action,
1899/// mode)` API reads from this map, so plugins displaying hints in
1900/// their UIs (overlay headers, status messages) can look up the
1901/// user's *actual* binding rather than hardcoding a key string.
1902///
1903/// This runs once at startup. If the user later edits their keymap
1904/// without restarting fresh, the labels go stale. That's acceptable
1905/// for v1 — keymap edits today already require a restart for full
1906/// effect; a subsequent commit can wire snapshot refresh into the
1907/// keymap-reload path.
1908#[cfg(feature = "plugins")]
1909fn populate_builtin_keybinding_labels(
1910 snapshot: &mut crate::services::plugins::api::EditorStateSnapshot,
1911 keybindings: &std::sync::Arc<std::sync::RwLock<crate::input::keybindings::KeybindingResolver>>,
1912) {
1913 use crate::input::keybindings::{Action, KeyContext};
1914 let Ok(resolver) = keybindings.read() else {
1915 return;
1916 };
1917 let contexts = [
1918 KeyContext::Normal,
1919 KeyContext::Prompt,
1920 KeyContext::Popup,
1921 KeyContext::Completion,
1922 KeyContext::FileExplorer,
1923 KeyContext::Menu,
1924 KeyContext::Terminal,
1925 KeyContext::Settings,
1926 KeyContext::CompositeBuffer,
1927 ];
1928 // Clear stale built-in entries first so a re-populate after
1929 // the user un-binds an action drops the label rather than
1930 // leaving the old key visible. Entries whose `\0<context>`
1931 // suffix isn't in our list are left alone — those belong to
1932 // plugin-defined buffer modes and have their own
1933 // re-population path in `handle_register_mode`.
1934 let known_suffixes: Vec<String> = contexts
1935 .iter()
1936 .map(|c| format!("\0{}", c.to_when_clause()))
1937 .collect();
1938 snapshot
1939 .keybinding_labels
1940 .retain(|k, _| !known_suffixes.iter().any(|s| k.ends_with(s)));
1941 // Built-in actions plus any plugin actions that are actually bound
1942 // (e.g. the Universal Search scope toggles `live_grep_toggle_*`), so
1943 // `getKeybindingLabel` can resolve a plugin control's accelerator.
1944 let plugin_action_names = resolver.bound_plugin_action_names();
1945 let action_names = Action::all_action_names()
1946 .into_iter()
1947 .chain(plugin_action_names);
1948 for action_name in action_names {
1949 for ctx in &contexts {
1950 if let Some(label) = resolver.find_keybinding_for_action(&action_name, ctx.clone()) {
1951 let key = format!("{}\0{}", action_name, ctx.to_when_clause());
1952 snapshot.keybinding_labels.insert(key, label);
1953 }
1954 }
1955 }
1956}