Skip to main content

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