Skip to main content

fresh/app/
editor_init.rs

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