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