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