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
90impl Editor {
91    /// Create a new editor with the given configuration and terminal dimensions
92    /// Uses system directories for state (recovery, sessions, etc.)
93    pub fn new(
94        config: Config,
95        width: u16,
96        height: u16,
97        dir_context: DirectoryContext,
98        color_capability: crate::view::color_support::ColorCapability,
99        filesystem: Arc<dyn FileSystem + Send + Sync>,
100    ) -> AnyhowResult<Self> {
101        Self::with_working_dir(
102            config,
103            width,
104            height,
105            None,
106            dir_context,
107            true,
108            color_capability,
109            filesystem,
110        )
111    }
112
113    /// Create a new editor with an explicit working directory
114    /// This is useful for testing with isolated temporary directories
115    #[allow(clippy::too_many_arguments)]
116    pub fn with_working_dir(
117        config: Config,
118        width: u16,
119        height: u16,
120        working_dir: Option<PathBuf>,
121        dir_context: DirectoryContext,
122        plugins_enabled: bool,
123        color_capability: crate::view::color_support::ColorCapability,
124        filesystem: Arc<dyn FileSystem + Send + Sync>,
125    ) -> AnyhowResult<Self> {
126        Self::with_working_dir_opts(
127            config,
128            width,
129            height,
130            working_dir,
131            dir_context,
132            plugins_enabled,
133            color_capability,
134            filesystem,
135            false,
136        )
137    }
138
139    /// Like [`Self::with_working_dir`] but with `defer_plugin_load`
140    /// exposed. When `true`, plugin loading is dispatched to the plugin
141    /// thread and the constructor returns immediately; results arrive
142    /// later via `AsyncMessage::PluginsDirLoaded` /
143    /// `PluginDeclarationsReady` and are applied in `process_async_messages`.
144    /// Used by the TUI startup path so the first frame draws without
145    /// waiting on TS parse/transpile/register.
146    #[allow(clippy::too_many_arguments)]
147    pub fn with_working_dir_opts(
148        config: Config,
149        width: u16,
150        height: u16,
151        working_dir: Option<PathBuf>,
152        dir_context: DirectoryContext,
153        plugins_enabled: bool,
154        color_capability: crate::view::color_support::ColorCapability,
155        filesystem: Arc<dyn FileSystem + Send + Sync>,
156        defer_plugin_load: bool,
157    ) -> AnyhowResult<Self> {
158        tracing::info!("Building default grammar registry...");
159        let start = std::time::Instant::now();
160        let mut grammar_registry = crate::primitives::grammar::GrammarRegistry::defaults_only();
161        // Merge user config so find_by_path respects user globs/filenames
162        // from the very first lookup. `defaults_only` just built the Arc, so
163        // we're the sole owner; get_mut is guaranteed to succeed. Assert
164        // rather than silently drop config — a failure here would leave the
165        // user wondering why their `*.conf → bash` rule doesn't highlight.
166        std::sync::Arc::get_mut(&mut grammar_registry)
167            .expect("defaults_only returned a shared Arc")
168            .apply_language_config(&config.languages);
169        tracing::info!("Default grammar registry built in {:?}", start.elapsed());
170        // Don't start background grammar build here — it's deferred to the
171        // first flush_pending_grammars() call so that plugin-registered grammars
172        // from the first event-loop tick are included in a single build.
173        Self::with_options(
174            config,
175            width,
176            height,
177            working_dir,
178            filesystem,
179            plugins_enabled,
180            true, // enable_embedded_plugins (production: always allow embedded fallback)
181            dir_context,
182            None,
183            color_capability,
184            grammar_registry,
185            defer_plugin_load,
186        )
187    }
188
189    /// Create a new editor for testing with custom backends
190    ///
191    /// By default uses empty grammar registry for fast initialization.
192    /// Pass `Some(registry)` for tests that need syntax highlighting or shebang detection.
193    ///
194    /// `enable_plugins` controls whether the plugin runtime is active at all.
195    /// `enable_embedded_plugins` separately gates the cargo-binstall embedded
196    /// plugins fallback — tests that pre-populate `<config_dir>/plugins/` and
197    /// want exact control over which plugins load can pass `false` here while
198    /// keeping `enable_plugins = true`.
199    #[allow(clippy::too_many_arguments)]
200    pub fn for_test(
201        config: Config,
202        width: u16,
203        height: u16,
204        working_dir: Option<PathBuf>,
205        dir_context: DirectoryContext,
206        color_capability: crate::view::color_support::ColorCapability,
207        filesystem: Arc<dyn FileSystem + Send + Sync>,
208        time_source: Option<SharedTimeSource>,
209        grammar_registry: Option<Arc<crate::primitives::grammar::GrammarRegistry>>,
210        enable_plugins: bool,
211        enable_embedded_plugins: bool,
212    ) -> AnyhowResult<Self> {
213        let mut grammar_registry =
214            grammar_registry.unwrap_or_else(crate::primitives::grammar::GrammarRegistry::empty);
215        // Merge user `[languages]` config into the catalog — production code
216        // does this at startup and again after the background grammar build,
217        // tests need the same so config-declared grammars/extensions resolve
218        // through `find_by_path`. Both call sites that feed into `for_test`
219        // (`HarnessOptions::with_full_grammar_registry` and the default
220        // `GrammarRegistry::empty()`) hand us the sole Arc owner.
221        std::sync::Arc::get_mut(&mut grammar_registry)
222            .expect("grammar registry Arc must be uniquely owned at for_test entry")
223            .apply_language_config(&config.languages);
224        let mut editor = Self::with_options(
225            config,
226            width,
227            height,
228            working_dir,
229            filesystem,
230            enable_plugins,
231            enable_embedded_plugins,
232            dir_context,
233            time_source,
234            color_capability,
235            grammar_registry,
236            false,
237        )?;
238        // Tests typically have no async_bridge, so the deferred grammar build
239        // would just drain pending_grammars and early-return. Skip it entirely.
240        editor.needs_full_grammar_build = false;
241        Ok(editor)
242    }
243
244    /// Create a new editor with custom options
245    /// This is primarily used for testing with slow or mock backends
246    /// to verify editor behavior under various I/O conditions
247    #[allow(clippy::too_many_arguments)]
248    fn with_options(
249        mut config: Config,
250        width: u16,
251        height: u16,
252        working_dir: Option<PathBuf>,
253        filesystem: Arc<dyn FileSystem + Send + Sync>,
254        enable_plugins: bool,
255        #[cfg_attr(not(feature = "embed-plugins"), allow(unused_variables))]
256        enable_embedded_plugins: bool,
257        dir_context: DirectoryContext,
258        time_source: Option<SharedTimeSource>,
259        color_capability: crate::view::color_support::ColorCapability,
260        grammar_registry: Arc<crate::primitives::grammar::GrammarRegistry>,
261        defer_plugin_load: bool,
262    ) -> AnyhowResult<Self> {
263        let mut t = InitTimer::start("Editor::with_options");
264        // Use provided time_source or default to RealTimeSource
265        let time_source = time_source.unwrap_or_else(RealTimeSource::shared);
266        tracing::info!("Editor::new called with width={}, height={}", width, height);
267
268        // Use provided working_dir or capture from environment
269        let working_dir = working_dir
270            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
271
272        // Canonicalize working_dir to resolve symlinks and normalize path components
273        // This ensures consistent path comparisons throughout the editor
274        let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
275
276        t.phase("preamble");
277        // Load all themes into registry
278        tracing::info!("Loading themes...");
279        let theme_loader = crate::view::theme::ThemeLoader::new(dir_context.themes_dir());
280        t.phase("ThemeLoader::new");
281        // Scan installed packages (language packs + bundles) before plugin loading.
282        // This replaces the JS loadInstalledPackages() — configs, grammars, plugin dirs,
283        // and theme dirs are all collected here and applied synchronously.
284        let scan_result =
285            crate::services::packages::scan_installed_packages(&dir_context.config_dir);
286        t.phase("scan_installed_packages");
287
288        // Apply package language configs (user config takes priority via or_insert)
289        for (lang_id, lang_config) in &scan_result.language_configs {
290            config
291                .languages
292                .entry(lang_id.clone())
293                .or_insert_with(|| lang_config.clone());
294        }
295
296        // Apply package LSP configs (user config takes priority via or_insert)
297        for (lang_id, lsp_config) in &scan_result.lsp_configs {
298            config
299                .lsp
300                .entry(lang_id.clone())
301                .or_insert_with(|| LspLanguageConfig::Multi(vec![lsp_config.clone()]));
302        }
303
304        let theme_registry = Arc::new(theme_loader.load_all(&scan_result.bundle_theme_dirs));
305        t.phase("theme_loader.load_all");
306        tracing::info!("Themes loaded");
307
308        // Get active theme from registry, falling back to default if not found
309        let theme = theme_registry.get_cloned(&config.theme).unwrap_or_else(|| {
310            tracing::warn!(
311                "Theme '{}' not found, falling back to default theme",
312                config.theme.0
313            );
314            theme_registry
315                .get_cloned(&crate::config::ThemeName(
316                    crate::view::theme::THEME_HIGH_CONTRAST.to_string(),
317                ))
318                .expect("Default theme must exist")
319        });
320
321        // Set terminal cursor color to match theme
322        theme.set_terminal_cursor_color();
323
324        t.phase("theme_setup");
325        let keybindings = Arc::new(RwLock::new(KeybindingResolver::new(&config)));
326        t.phase("keybindings");
327
328        // Create an empty initial buffer
329        let mut buffers = HashMap::new();
330        let mut event_logs = HashMap::new();
331
332        // Buffer IDs start at 1 (not 0) because the plugin API returns 0 to
333        // mean "no active buffer" from getActiveBufferId().  JavaScript treats
334        // 0 as falsy (`if (!bufferId)` would wrongly reject buffer 0), so
335        // using 1-based IDs avoids this entire class of bugs in plugins.
336        let buffer_id = BufferId(1);
337        let mut state = EditorState::new(
338            width,
339            height,
340            config.editor.large_file_threshold_bytes as usize,
341            Arc::clone(&filesystem),
342        );
343        // Configure initial buffer settings from config
344        state
345            .margins
346            .configure_for_line_numbers(config.editor.line_numbers);
347        state.buffer_settings.tab_size = config.editor.tab_size;
348        state.buffer_settings.auto_close = config.editor.auto_close;
349        // Note: line_wrap_enabled is now stored in SplitViewState.viewport
350        tracing::info!("EditorState created for buffer {:?}", buffer_id);
351        buffers.insert(buffer_id, state);
352        event_logs.insert(buffer_id, EventLog::new());
353
354        // Create metadata for the initial empty buffer
355        let mut buffer_metadata = HashMap::new();
356        buffer_metadata.insert(buffer_id, BufferMetadata::new());
357
358        // Initialize LSP manager with current working directory as root
359        let root_uri = types::file_path_to_lsp_uri(&working_dir);
360
361        t.phase("buffer_state");
362        // Create Tokio runtime for async I/O (LSP, file watching, git, etc.)
363        let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
364            .worker_threads(2) // Small pool for I/O tasks
365            .thread_name("editor-async")
366            .enable_all()
367            .build()
368            .ok();
369        t.phase("tokio_runtime");
370
371        // Create async bridge for communication
372        let async_bridge = AsyncBridge::new();
373
374        if tokio_runtime.is_none() {
375            tracing::warn!("Failed to create Tokio runtime - async features disabled");
376        }
377
378        // Create LSP manager with async support
379        let mut lsp = LspManager::new(root_uri);
380
381        // Configure runtime and bridge if available
382        if let Some(ref runtime) = tokio_runtime {
383            lsp.set_runtime(runtime.handle().clone(), async_bridge.clone());
384        }
385
386        // Configure LSP servers from config
387        for (language, lsp_configs) in &config.lsp {
388            lsp.set_language_configs(language.clone(), lsp_configs.as_slice().to_vec());
389        }
390
391        // Configure universal (global) LSP servers — spawned once, shared across languages
392        let universal_servers: Vec<LspServerConfig> = config
393            .universal_lsp
394            .values()
395            .flat_map(|lc| lc.as_slice().to_vec())
396            .filter(|c| c.enabled)
397            .collect();
398        lsp.set_universal_configs(universal_servers);
399
400        // Auto-detect Deno projects: if deno.json or deno.jsonc exists in the
401        // workspace root, override JS/TS LSP to use `deno lsp` (#1191)
402        if working_dir.join("deno.json").exists() || working_dir.join("deno.jsonc").exists() {
403            tracing::info!("Detected Deno project (deno.json found), using deno lsp for JS/TS");
404            let deno_config = LspServerConfig {
405                command: "deno".to_string(),
406                args: vec!["lsp".to_string()],
407                enabled: true,
408                auto_start: false,
409                process_limits: ProcessLimits::default(),
410                initialization_options: Some(serde_json::json!({"enable": true})),
411                ..Default::default()
412            };
413            lsp.set_language_config("javascript".to_string(), deno_config.clone());
414            lsp.set_language_config("typescript".to_string(), deno_config);
415        }
416
417        t.phase("lsp_setup");
418        // Initialize split manager with the initial buffer
419        let split_manager = SplitManager::new(buffer_id);
420
421        // Initialize per-split view state for the initial split
422        let mut split_view_states = HashMap::new();
423        let initial_split_id = split_manager.active_split();
424        let mut initial_view_state = SplitViewState::with_buffer(width, height, buffer_id);
425        initial_view_state.apply_config_defaults(
426            config.editor.line_numbers,
427            config.editor.highlight_current_line,
428            config.editor.line_wrap,
429            config.editor.wrap_indent,
430            config.editor.wrap_column,
431            config.editor.rulers.clone(),
432        );
433        split_view_states.insert(initial_split_id, initial_view_state);
434
435        // Initialize filesystem manager for file explorer
436        let fs_manager = Arc::new(FsManager::new(Arc::clone(&filesystem)));
437
438        // Initialize command registry (always available, used by both plugins and core)
439        let command_registry = Arc::new(RwLock::new(CommandRegistry::new()));
440
441        // Construct the boot-time authority. Per principle 6, the editor
442        // always boots with a local authority and renders immediately;
443        // SSH startup and plugins replace it via `install_authority`
444        // after their async work is done. The supplied `filesystem`
445        // overrides the local default to support tests that mock IO.
446        let authority = crate::services::authority::Authority {
447            filesystem: Arc::clone(&filesystem),
448            ..crate::services::authority::Authority::local()
449        };
450        let process_spawner = Arc::clone(&authority.process_spawner);
451
452        // Initialize Quick Open registry with all providers
453        let mut quick_open_registry = QuickOpenRegistry::new();
454        quick_open_registry.register(Box::new(FileProvider::new(
455            Arc::clone(&filesystem),
456            Arc::clone(&process_spawner),
457            tokio_runtime.as_ref().map(|rt| rt.handle().clone()),
458            Some(async_bridge.sender()),
459        )));
460        quick_open_registry.register(Box::new(CommandProvider::new(
461            Arc::clone(&command_registry),
462            Arc::clone(&keybindings),
463        )));
464        quick_open_registry.register(Box::new(BufferProvider::new()));
465        quick_open_registry.register(Box::new(GotoLineProvider::new()));
466
467        // Build shared theme cache for plugin access
468        let theme_cache = Arc::new(RwLock::new(theme_registry.to_json_map()));
469
470        t.phase("split_quickopen_authority");
471        // Initialize plugin manager (handles both enabled and disabled cases internally)
472        let plugin_manager = PluginManager::new(
473            enable_plugins,
474            Arc::clone(&command_registry),
475            dir_context.clone(),
476            Arc::clone(&theme_cache),
477        );
478        t.phase("PluginManager::new");
479
480        // Update the plugin state snapshot with working_dir BEFORE loading plugins
481        // This ensures plugins can call getCwd() correctly during initialization
482        #[cfg(feature = "plugins")]
483        if let Some(snapshot_handle) = plugin_manager.state_snapshot_handle() {
484            let mut snapshot = snapshot_handle.write().unwrap();
485            snapshot.working_dir = working_dir.clone();
486            // Pre-populate keybinding labels for the static built-in
487            // keymap so `editor.getKeybindingLabel(action, context)`
488            // works for actions that aren't behind a plugin-defined
489            // buffer mode. Without this, a plugin asking
490            // `getKeybindingLabel("cycle_live_grep_provider",
491            // "prompt")` gets null even though Alt+P is bound, and
492            // ends up hardcoding the key in its UI.
493            populate_builtin_keybinding_labels(&mut snapshot, &keybindings);
494        }
495
496        // Load TypeScript plugins from multiple directories:
497        // 1. Next to the executable (for cargo-dist installations)
498        // 2. From embedded plugins (for cargo-binstall and `cargo run`,
499        //    when embed-plugins feature is enabled)
500        // 3. User plugins directory (~/.config/fresh/plugins)
501        // 4. Package manager installed plugins (~/.config/fresh/plugins/packages/*)
502        if plugin_manager.is_active() {
503            let mut plugin_dirs: Vec<std::path::PathBuf> = vec![];
504
505            // Check next to executable first (for cargo-dist installations)
506            if let Ok(exe_path) = std::env::current_exe() {
507                if let Some(exe_dir) = exe_path.parent() {
508                    let exe_plugin_dir = exe_dir.join("plugins");
509                    if exe_plugin_dir.exists() {
510                        plugin_dirs.push(exe_plugin_dir);
511                    }
512                }
513            }
514
515            // No working-directory `plugins/` check: a user project with a
516            // folder named `plugins/` (e.g. a Vite/Rollup project, a Hugo
517            // site) is not a Fresh plugin source. Bundled plugins for the
518            // dev workflow come in via the embedded fallback below; user
519            // plugins live under `<config_dir>/plugins/`. See issue #1722.
520
521            // If no disk plugins found, try embedded plugins (cargo-binstall builds).
522            // `enable_embedded_plugins` lets tests opt out so they get exactly
523            // the plugin set they pre-populated under `<config_dir>/plugins/`,
524            // without the bundled set leaking in.
525            #[cfg(feature = "embed-plugins")]
526            if enable_embedded_plugins && plugin_dirs.is_empty() {
527                if let Some(embedded_dir) =
528                    crate::services::plugins::embedded::get_embedded_plugins_dir()
529                {
530                    tracing::info!("Using embedded plugins from: {:?}", embedded_dir);
531                    plugin_dirs.push(embedded_dir.clone());
532                }
533            }
534
535            // Always check user config plugins directory (~/.config/fresh/plugins)
536            let user_plugins_dir = dir_context.config_dir.join("plugins");
537            if user_plugins_dir.exists() && !plugin_dirs.contains(&user_plugins_dir) {
538                tracing::info!("Found user plugins directory: {:?}", user_plugins_dir);
539                plugin_dirs.push(user_plugins_dir.clone());
540            }
541
542            // Check for package manager installed plugins (~/.config/fresh/plugins/packages/*)
543            let packages_dir = dir_context.config_dir.join("plugins").join("packages");
544            if packages_dir.exists() {
545                if let Ok(entries) = std::fs::read_dir(&packages_dir) {
546                    for entry in entries.flatten() {
547                        let path = entry.path();
548                        // Skip hidden directories (like .index for registry cache)
549                        if path.is_dir() {
550                            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
551                                if !name.starts_with('.') {
552                                    tracing::info!("Found package manager plugin: {:?}", path);
553                                    plugin_dirs.push(path);
554                                }
555                            }
556                        }
557                    }
558                }
559            }
560
561            // Add bundle plugin directories from package scan
562            for dir in &scan_result.bundle_plugin_dirs {
563                tracing::info!("Found bundle plugin directory: {:?}", dir);
564                plugin_dirs.push(dir.clone());
565            }
566
567            if plugin_dirs.is_empty() {
568                tracing::debug!(
569                    "No plugins directory found next to executable or in working dir: {:?}",
570                    working_dir
571                );
572            }
573
574            if defer_plugin_load {
575                // Async startup path: hand each dir + a trailing
576                // ListPlugins request to the plugin thread now, return
577                // before they finish, and let a forwarder thread
578                // translate the responses into AsyncMessages that the
579                // main loop applies via `process_async_messages`. The
580                // plugin thread is FIFO, so submitting in this exact
581                // order guarantees declarations cover only the startup
582                // batch — init.ts and lifecycle hooks queue *after*
583                // ListPlugins from main.rs after construction returns,
584                // matching the original blocking behaviour.
585                #[cfg(feature = "plugins")]
586                {
587                    let bridge = &async_bridge;
588                    let mut dir_receivers: Vec<(
589                        std::path::PathBuf,
590                        fresh_plugin_runtime::thread::oneshot::Receiver<
591                            fresh_plugin_runtime::thread::PluginsDirLoadResult,
592                        >,
593                    )> = Vec::with_capacity(plugin_dirs.len());
594                    for plugin_dir in &plugin_dirs {
595                        tracing::info!(
596                            "Submitting async TypeScript plugin load for: {:?}",
597                            plugin_dir
598                        );
599                        if let Some(rx) = plugin_manager
600                            .load_plugins_from_dir_with_config_request(plugin_dir, &config.plugins)
601                        {
602                            dir_receivers.push((plugin_dir.clone(), rx));
603                        }
604                    }
605                    let declarations_rx = if !dir_receivers.is_empty() {
606                        plugin_manager.list_plugins_request()
607                    } else {
608                        None
609                    };
610                    if !dir_receivers.is_empty() {
611                        let sender = bridge.sender();
612                        std::thread::Builder::new()
613                            .name("plugin-load-forwarder".to_string())
614                            .spawn(move || {
615                                for (dir, rx) in dir_receivers {
616                                    let load_start = std::time::Instant::now();
617                                    match rx.recv() {
618                                        Ok((errors, discovered_plugins)) => {
619                                            tracing::info!(
620                                                "Loaded TypeScript plugins from {:?} in {:?}",
621                                                dir,
622                                                load_start.elapsed()
623                                            );
624                                            drop(sender.send(
625                                                crate::services::async_bridge::AsyncMessage::PluginsDirLoaded {
626                                                    dir,
627                                                    errors,
628                                                    discovered_plugins,
629                                                },
630                                            ));
631                                        }
632                                        Err(e) => {
633                                            tracing::warn!(
634                                                "plugin-load-forwarder: dir {:?} recv failed: {}",
635                                                dir,
636                                                e
637                                            );
638                                        }
639                                    }
640                                }
641                                if let Some(rx) = declarations_rx {
642                                    match rx.recv() {
643                                        Ok(plugin_infos) => {
644                                            let declarations: Vec<(String, String)> = plugin_infos
645                                                .into_iter()
646                                                .filter_map(|info| {
647                                                    info.declarations.map(|d| (info.name, d))
648                                                })
649                                                .collect();
650                                            drop(sender.send(
651                                                crate::services::async_bridge::AsyncMessage::PluginDeclarationsReady {
652                                                    declarations,
653                                                },
654                                            ));
655                                        }
656                                        Err(e) => {
657                                            tracing::warn!(
658                                                "plugin-load-forwarder: list_plugins recv failed: {}",
659                                                e
660                                            );
661                                        }
662                                    }
663                                }
664                            })
665                            .ok();
666                    }
667                }
668            } else {
669                // Synchronous (legacy / test) path. Used by `for_test`,
670                // server, GUI: every other code path that wants the
671                // editor fully constructed before the constructor
672                // returns.
673                for plugin_dir in plugin_dirs {
674                    tracing::info!("Loading TypeScript plugins from: {:?}", plugin_dir);
675                    let load_start = std::time::Instant::now();
676                    let (errors, discovered_plugins) = plugin_manager
677                        .load_plugins_from_dir_with_config(&plugin_dir, &config.plugins);
678                    tracing::info!(
679                        "Loaded TypeScript plugins from {:?} in {:?}",
680                        plugin_dir,
681                        load_start.elapsed()
682                    );
683
684                    // Merge discovered plugins into config
685                    // discovered_plugins already contains the merged config (saved enabled state + discovered path)
686                    for (name, plugin_config) in discovered_plugins {
687                        config.plugins.insert(name, plugin_config);
688                    }
689
690                    if !errors.is_empty() {
691                        for err in &errors {
692                            tracing::error!("TypeScript plugin load error: {}", err);
693                        }
694                        // In debug/test builds, panic to surface plugin loading errors
695                        #[cfg(debug_assertions)]
696                        panic!(
697                            "TypeScript plugin loading failed with {} error(s): {}",
698                            errors.len(),
699                            errors.join("; ")
700                        );
701                    }
702                }
703
704                // Collect `.d.ts` emits from every loaded plugin into a
705                // single aggregate under `<config_dir>/types/plugins.d.ts`.
706                // This is what makes `getPluginApi("foo")` typed in the
707                // user's init.ts without a hand-written cast — each plugin
708                // that uses `declare global { interface FreshPluginRegistry }`
709                // contributes its augmentation, and init.ts's tsconfig
710                // picks the aggregate up via `files`.
711                let declarations = plugin_manager.plugin_declarations();
712                crate::init_script::write_plugin_declarations(
713                    &dir_context.config_dir,
714                    &declarations,
715                );
716            }
717        }
718
719        t.phase("plugin_loading");
720        // Extract config values before moving config into the struct
721        let file_explorer_width = config.file_explorer.width;
722        let file_explorer_side = config.file_explorer.side;
723        let recovery_enabled = config.editor.recovery_enabled;
724        let check_for_updates = config.check_for_updates;
725        let show_menu_bar = config.editor.show_menu_bar;
726        let show_tab_bar = config.editor.show_tab_bar;
727        let show_status_bar = config.editor.show_status_bar;
728        let show_prompt_line = config.editor.show_prompt_line;
729
730        // Start periodic update checker if enabled (also sends daily telemetry)
731        let update_checker = if check_for_updates {
732            tracing::debug!("Update checking enabled, starting periodic checker");
733            Some(
734                crate::services::release_checker::start_periodic_update_check(
735                    crate::services::release_checker::DEFAULT_RELEASES_URL,
736                    time_source.clone(),
737                    dir_context.data_dir.clone(),
738                ),
739            )
740        } else {
741            tracing::debug!("Update checking disabled by config");
742            None
743        };
744
745        // Cache raw user config at startup (to avoid re-reading file every frame)
746        let user_config_raw = Config::read_user_config_raw(&working_dir);
747
748        // Wrap config in Arc and pre-seed the snapshot mirror + JSON cache.
749        // Doing this at construction means the strong count of the live
750        // `config` Arc starts at 2 and stays there: every `Arc::make_mut`
751        // call on `config` is forced to CoW, so no mutation path (direct or
752        // via `config_mut()`) can leave `config_cached_json` referring to
753        // stale memory.
754        let config_arc = Arc::new(config);
755        let config_cached_json =
756            Arc::new(serde_json::to_value(&*config_arc).unwrap_or(serde_json::Value::Null));
757        let config_snapshot_anchor = Arc::clone(&config_arc);
758
759        let mut editor = Editor {
760            buffers,
761            event_logs,
762            next_buffer_id: 2,
763            config: config_arc,
764            config_snapshot_anchor,
765            config_cached_json,
766            user_config_raw: Arc::new(user_config_raw),
767            dir_context: dir_context.clone(),
768            grammar_registry,
769            pending_grammars: scan_result
770                .additional_grammars
771                .iter()
772                .map(|g| PendingGrammar {
773                    language: g.language.clone(),
774                    grammar_path: g.path.to_string_lossy().to_string(),
775                    extensions: g.extensions.clone(),
776                })
777                .collect(),
778            grammar_reload_pending: false,
779            grammar_build_in_progress: false,
780            needs_full_grammar_build: true,
781            streaming_grep_cancellation: None,
782            pending_grammar_callbacks: Vec::new(),
783            theme,
784            theme_registry,
785            expanded_menus_cache: crate::view::ui::ExpandedMenusCache::default(),
786            theme_cache,
787            ansi_background: None,
788            ansi_background_path: None,
789            background_fade: crate::primitives::ansi_background::DEFAULT_BACKGROUND_FADE,
790            keybindings,
791            clipboard: crate::services::clipboard::Clipboard::new(),
792            should_quit: false,
793            should_detach: false,
794            session_mode: false,
795            software_cursor_only: false,
796            session_name: None,
797            pending_escape_sequences: Vec::new(),
798            restart_with_dir: None,
799            status_message: None,
800            plugin_status_message: None,
801            last_window_title: None,
802            plugin_errors: Vec::new(),
803            prompt: None,
804            terminal_width: width,
805            terminal_height: height,
806            lsp: Some(lsp),
807            buffer_metadata,
808            mode_registry: ModeRegistry::new(),
809            tokio_runtime,
810            async_bridge: Some(async_bridge),
811            split_manager,
812            split_view_states,
813            previous_viewports: HashMap::new(),
814            scroll_sync_manager: ScrollSyncManager::new(),
815            file_explorer: None,
816            preview: None,
817            suppress_position_history_once: false,
818            fs_manager,
819            authority,
820            pending_authority: None,
821            remote_indicator_override: None,
822            local_filesystem: Arc::new(crate::model::filesystem::StdFileSystem),
823            file_explorer_visible: false,
824            file_explorer_sync_in_progress: false,
825            file_explorer_width,
826            file_explorer_side,
827            pending_file_explorer_show_hidden: None,
828            pending_file_explorer_show_gitignored: None,
829            menu_bar_visible: show_menu_bar,
830            file_explorer_decorations: HashMap::new(),
831            file_explorer_decoration_cache:
832                crate::view::file_tree::FileExplorerDecorationCache::default(),
833            file_explorer_clipboard: None,
834            menu_bar_auto_shown: false,
835            tab_bar_visible: show_tab_bar,
836            status_bar_visible: show_status_bar,
837            prompt_line_visible: show_prompt_line,
838            mouse_enabled: true,
839            same_buffer_scroll_sync: false,
840            mouse_cursor_position: None,
841            gpm_active: false,
842            key_context: KeyContext::Normal,
843            menu_state: crate::view::ui::MenuState::new(dir_context.themes_dir()),
844            menus: crate::config::MenuConfig::translated(),
845            working_dir: working_dir.clone(),
846            position_history: PositionHistory::new(),
847            in_navigation: false,
848            next_lsp_request_id: 0,
849            pending_completion_requests: HashSet::new(),
850            completion_items: None,
851            scheduled_completion_trigger: None,
852            completion_service: crate::services::completion::CompletionService::new(),
853            dabbrev_state: None,
854            pending_goto_definition_request: None,
855            hover: hover::HoverState::default(),
856            pending_references_request: None,
857            pending_references_symbol: String::new(),
858            pending_signature_help_request: None,
859            pending_code_actions_requests: HashSet::new(),
860            pending_code_actions_server_names: HashMap::new(),
861            pending_code_actions: None,
862            pending_inlay_hints_requests: HashMap::new(),
863            pending_folding_range_requests: HashMap::new(),
864            folding_ranges_in_flight: HashMap::new(),
865            folding_ranges_debounce: HashMap::new(),
866            pending_semantic_token_requests: HashMap::new(),
867            semantic_tokens_in_flight: HashMap::new(),
868            pending_semantic_token_range_requests: HashMap::new(),
869            semantic_tokens_range_in_flight: HashMap::new(),
870            semantic_tokens_range_last_request: HashMap::new(),
871            semantic_tokens_range_applied: HashMap::new(),
872            semantic_tokens_full_debounce: HashMap::new(),
873            search_state: None,
874            search_namespace: crate::view::overlay::OverlayNamespace::from_string(
875                "search".to_string(),
876            ),
877            lsp_diagnostic_namespace: crate::view::overlay::OverlayNamespace::from_string(
878                "lsp-diagnostic".to_string(),
879            ),
880            pending_search_range: None,
881            interactive_replace_state: None,
882            mouse_state: MouseState::default(),
883            tab_context_menu: None,
884            file_explorer_context_menu: None,
885            theme_info_popup: None,
886            cached_layout: CachedLayout::default(),
887            command_registry,
888            quick_open_registry,
889            plugin_manager,
890            plugin_dev_workspaces: HashMap::new(),
891            seen_byte_ranges: HashMap::new(),
892            panel_ids: HashMap::new(),
893            live_grep_last_state: None,
894            overlay_preview_state: None,
895            buffer_groups: HashMap::new(),
896            buffer_to_group: HashMap::new(),
897            next_buffer_group_id: 0,
898            grouped_subtrees: HashMap::new(),
899            background_process_handles: HashMap::new(),
900            host_process_handles: HashMap::new(),
901            prompt_histories: {
902                // Load prompt histories from disk if available
903                let mut histories = HashMap::new();
904                for history_name in ["search", "replace", "goto_line"] {
905                    let path = dir_context.prompt_history_path(history_name);
906                    let history = crate::input::input_history::InputHistory::load_from_file(&path)
907                        .unwrap_or_else(|e| {
908                            tracing::warn!("Failed to load {} history: {}", history_name, e);
909                            crate::input::input_history::InputHistory::new()
910                        });
911                    histories.insert(history_name.to_string(), history);
912                }
913                histories
914            },
915            pending_async_prompt_callback: None,
916            pending_next_key_callbacks: std::collections::VecDeque::new(),
917            key_capture_active: false,
918            pending_key_capture_buffer: std::collections::VecDeque::new(),
919            goto_line_preview: None,
920            lsp_progress: std::collections::HashMap::new(),
921            lsp_server_statuses: std::collections::HashMap::new(),
922            lsp_window_messages: Vec::new(),
923            lsp_log_messages: Vec::new(),
924            diagnostic_result_ids: HashMap::new(),
925            scheduled_diagnostic_pull: None,
926            scheduled_inlay_hints_request: None,
927            stored_push_diagnostics: HashMap::new(),
928            stored_pull_diagnostics: HashMap::new(),
929            stored_diagnostics: Arc::new(HashMap::new()),
930            stored_folding_ranges: Arc::new(HashMap::new()),
931            event_broadcaster: crate::model::control_event::EventBroadcaster::default(),
932            bookmarks: bookmarks::BookmarkState::default(),
933            search_case_sensitive: true,
934            search_whole_word: false,
935            search_use_regex: false,
936            search_confirm_each: false,
937            macros: macros::MacroState::default(),
938            #[cfg(feature = "plugins")]
939            pending_plugin_actions: Vec::new(),
940            #[cfg(feature = "plugins")]
941            plugin_render_requested: false,
942            chord_state: Vec::new(),
943            user_dismissed_lsp_languages: std::collections::HashSet::new(),
944            pending_close_buffer: None,
945            pending_quit_unnamed_save: Vec::new(),
946            auto_revert_enabled: true,
947            last_auto_revert_poll: time_source.now(),
948            last_file_tree_poll: time_source.now(),
949            git_index_resolved: false,
950            file_mod_times: HashMap::new(),
951            dir_mod_times: HashMap::new(),
952            pending_file_poll_rx: None,
953            pending_dir_poll_rx: None,
954            file_rapid_change_counts: HashMap::new(),
955            file_open_state: None,
956            file_browser_layout: None,
957            recovery_service: {
958                let recovery_config = RecoveryConfig {
959                    enabled: recovery_enabled,
960                    ..RecoveryConfig::default()
961                };
962                // Default to a CWD-scoped recovery directory so each working
963                // directory keeps its own hot-exit recovery files. If this
964                // editor is later promoted to session mode, `set_session_name`
965                // re-creates the service with `RecoveryScope::Session`.
966                // Issue #1550: without per-CWD scoping, opening Fresh in a
967                // second folder would clobber the first folder's unsaved
968                // unnamed buffers on shutdown.
969                let scope = crate::services::recovery::RecoveryScope::Standalone {
970                    working_dir: working_dir.clone(),
971                };
972                RecoveryService::with_scope(recovery_config, &dir_context.recovery_dir(), &scope)
973            },
974            full_redraw_requested: false,
975            suspend_requested: false,
976            time_source: time_source.clone(),
977            last_auto_recovery_save: time_source.now(),
978            last_persistent_auto_save: time_source.now(),
979            active_custom_contexts: HashSet::new(),
980            plugin_global_state: HashMap::new(),
981            editor_mode: None,
982            warning_log: None,
983            status_log_path: None,
984            warning_domains: WarningDomainRegistry::new(),
985            update_checker,
986            terminal_manager: crate::services::terminal::TerminalManager::new(),
987            terminal_buffers: HashMap::new(),
988            terminal_backing_files: HashMap::new(),
989            terminal_log_files: HashMap::new(),
990            ephemeral_terminals: std::collections::HashSet::new(),
991            terminal_mode: false,
992            keyboard_capture: false,
993            terminal_mode_resume: std::collections::HashSet::new(),
994            previous_click_time: None,
995            previous_click_position: None,
996            click_count: 0,
997            settings_state: None,
998            calibration_wizard: None,
999            event_debug: None,
1000            keybinding_editor: None,
1001            key_translator: crate::input::key_translator::KeyTranslator::load_from_config_dir(
1002                &dir_context.config_dir,
1003            )
1004            .unwrap_or_default(),
1005            color_capability,
1006            pending_file_opens: Vec::new(),
1007            pending_hot_exit_recovery: false,
1008            wait_tracking: HashMap::new(),
1009            completed_waits: Vec::new(),
1010            stdin_stream: stdin_stream::StdinStream::default(),
1011            line_scan: line_scan::LineScan::default(),
1012            search_scan: search_scan::SearchScan::default(),
1013            search_overlay_top_byte: None,
1014            review_hunks: Vec::new(),
1015            global_popups: crate::view::popup::PopupManager::new(),
1016            composite_buffers: HashMap::new(),
1017            composite_view_states: HashMap::new(),
1018            animations: crate::view::animation::AnimationRunner::new(),
1019            previous_cursor_screen_pos: None,
1020            cursor_jump_animation: None,
1021            pending_vb_animations: Vec::new(),
1022        };
1023
1024        t.phase("editor_struct_assembly");
1025        // Apply clipboard configuration
1026        editor.clipboard.apply_config(&editor.config.clipboard);
1027
1028        #[cfg(feature = "plugins")]
1029        {
1030            editor.update_plugin_state_snapshot();
1031            if editor.plugin_manager.is_active() {
1032                editor.plugin_manager.run_hook(
1033                    "editor_initialized",
1034                    crate::services::plugins::hooks::HookArgs::EditorInitialized {},
1035                );
1036            }
1037        }
1038        t.phase("post_struct_hooks");
1039        t.finish();
1040        Ok(editor)
1041    }
1042
1043    /// Get a reference to the event broadcaster
1044    pub fn event_broadcaster(&self) -> &crate::model::control_event::EventBroadcaster {
1045        &self.event_broadcaster
1046    }
1047
1048    /// Spawn a background thread to build the full grammar registry
1049    /// (embedded grammars, user grammars, language packs, and any plugin-registered grammars).
1050    /// Called on the first event-loop tick (via `flush_pending_grammars`) so that
1051    /// plugin grammars registered during init are included in a single build.
1052    pub(super) fn start_background_grammar_build(
1053        &mut self,
1054        additional: Vec<crate::primitives::grammar::GrammarSpec>,
1055        callback_ids: Vec<fresh_core::api::JsCallbackId>,
1056    ) {
1057        let Some(bridge) = &self.async_bridge else {
1058            return;
1059        };
1060        self.grammar_build_in_progress = true;
1061        let sender = bridge.sender();
1062        let config_dir = self.dir_context.config_dir.clone();
1063        tracing::info!(
1064            "Spawning background grammar build thread ({} plugin grammars)...",
1065            additional.len()
1066        );
1067        std::thread::Builder::new()
1068            .name("grammar-build".to_string())
1069            .spawn(move || {
1070                tracing::info!("[grammar-build] Thread started");
1071                let start = std::time::Instant::now();
1072                let registry = if additional.is_empty() {
1073                    crate::primitives::grammar::GrammarRegistry::for_editor(config_dir)
1074                } else {
1075                    crate::primitives::grammar::GrammarRegistry::for_editor_with_additional(
1076                        config_dir,
1077                        &additional,
1078                    )
1079                };
1080                tracing::info!("[grammar-build] Complete in {:?}", start.elapsed());
1081                drop(sender.send(
1082                    crate::services::async_bridge::AsyncMessage::GrammarRegistryBuilt {
1083                        registry,
1084                        callback_ids,
1085                    },
1086                ));
1087            })
1088            .ok();
1089    }
1090
1091    // =========================================================================
1092    // init.ts / runtime-overlay surface (design docs §3–§6)
1093    // =========================================================================
1094
1095    /// Auto-load `~/.config/fresh/init.ts` if present, through the existing
1096    /// plugin pipeline under the stable name `crate::init_script::INIT_PLUGIN_NAME`.
1097    pub fn load_init_script(&mut self, enabled: bool) {
1098        use crate::init_script::{
1099            check, decide_load, describe, record_success, refresh_types_scaffolding, CheckSeverity,
1100            InitOutcome, LoadDecision,
1101        };
1102
1103        let config_dir = self.dir_context.config_dir.clone();
1104
1105        if enabled {
1106            // Refresh the types mirror from the embedded copy before anything
1107            // reads init.ts. Guarantees the declarations the user sees match
1108            // the running build — stale types would hide API drift.
1109            refresh_types_scaffolding(&config_dir);
1110
1111            // Re-check init.ts right after the refresh so drift between the
1112            // user's script and the current API surface (at least syntax-level
1113            // fallout like unterminated blocks from a botched rename) shows up
1114            // in the log immediately rather than only at eval time.
1115            let report = check(&config_dir);
1116            if !report.ok {
1117                for d in &report.diagnostics {
1118                    let level = match d.severity {
1119                        CheckSeverity::Error => "error",
1120                        CheckSeverity::Warning => "warning",
1121                    };
1122                    tracing::warn!(
1123                        "init.ts pre-load {level} at {}:{}: {}",
1124                        d.line,
1125                        d.column,
1126                        d.message
1127                    );
1128                }
1129            }
1130        }
1131
1132        let outcome = match decide_load(&config_dir, enabled) {
1133            LoadDecision::Skip(outcome) => outcome,
1134            LoadDecision::Load { source } => {
1135                if !self.plugin_manager.is_active() {
1136                    InitOutcome::Failed {
1137                        message: "plugin runtime inactive (--no-plugins); init.ts cannot run"
1138                            .into(),
1139                    }
1140                } else {
1141                    match self.plugin_manager.load_plugin_from_source(
1142                        &source,
1143                        crate::init_script::INIT_PLUGIN_NAME,
1144                        true,
1145                    ) {
1146                        Ok(()) => {
1147                            record_success(&config_dir);
1148                            InitOutcome::Loaded
1149                        }
1150                        Err(e) => InitOutcome::Failed {
1151                            message: format!("{e}"),
1152                        },
1153                    }
1154                }
1155            }
1156        };
1157
1158        let summary = describe(&outcome);
1159        match outcome {
1160            InitOutcome::NotFound | InitOutcome::Disabled => tracing::debug!("{}", summary),
1161            InitOutcome::Loaded => tracing::info!("{}", summary),
1162            InitOutcome::CrashFused { .. } | InitOutcome::Failed { .. } => {
1163                tracing::warn!("{}", summary);
1164                self.set_status_message(summary);
1165            }
1166        }
1167    }
1168
1169    /// Non-blocking variant of [`Self::load_init_script`] for the TUI
1170    /// startup path. Does the synchronous pre-work (types scaffolding
1171    /// refresh, syntax check, fuse check), then either submits the
1172    /// `LoadPluginFromSource` request to the plugin thread and spawns a
1173    /// forwarder that translates the result into
1174    /// `AsyncMessage::PluginInitScriptLoaded`, or — for the `Skip(...)`
1175    /// outcomes — emits the message directly so the same async-dispatch
1176    /// handler logs and applies status. The request goes through the
1177    /// same FIFO channel as the startup plugin loads, so by the time the
1178    /// plugin thread evaluates init.ts every batch plugin has already
1179    /// finished — preserving the original load ordering.
1180    pub fn load_init_script_async(&mut self, enabled: bool) {
1181        use crate::init_script::{
1182            check, decide_load, refresh_types_scaffolding, CheckSeverity, InitOutcome, LoadDecision,
1183        };
1184        use crate::services::async_bridge::PluginInitScriptOutcome;
1185
1186        let config_dir = self.dir_context.config_dir.clone();
1187
1188        if enabled {
1189            refresh_types_scaffolding(&config_dir);
1190            let report = check(&config_dir);
1191            if !report.ok {
1192                for d in &report.diagnostics {
1193                    let level = match d.severity {
1194                        CheckSeverity::Error => "error",
1195                        CheckSeverity::Warning => "warning",
1196                    };
1197                    tracing::warn!(
1198                        "init.ts pre-load {level} at {}:{}: {}",
1199                        d.line,
1200                        d.column,
1201                        d.message
1202                    );
1203                }
1204            }
1205        }
1206
1207        let outcome_now: Option<PluginInitScriptOutcome> = match decide_load(&config_dir, enabled) {
1208            LoadDecision::Skip(outcome) => Some(match outcome {
1209                InitOutcome::NotFound => PluginInitScriptOutcome::NotFound,
1210                InitOutcome::Disabled => PluginInitScriptOutcome::Disabled,
1211                InitOutcome::CrashFused { failures } => {
1212                    PluginInitScriptOutcome::CrashFused { failures }
1213                }
1214                // decide_load only returns these via Load; keep total to
1215                // satisfy the matcher.
1216                InitOutcome::Loaded => PluginInitScriptOutcome::Loaded,
1217                InitOutcome::Failed { message } => PluginInitScriptOutcome::Failed { message },
1218            }),
1219            LoadDecision::Load { source } => {
1220                if !self.plugin_manager.is_active() {
1221                    Some(PluginInitScriptOutcome::Failed {
1222                        message: "plugin runtime inactive (--no-plugins); init.ts cannot run"
1223                            .into(),
1224                    })
1225                } else {
1226                    self.spawn_init_script_forwarder(source);
1227                    None
1228                }
1229            }
1230        };
1231
1232        if let Some(outcome) = outcome_now {
1233            // Skip / fused / inactive paths: emit through the bridge so
1234            // the same handler runs them as the success path. Falls back
1235            // to direct application if the bridge is missing (test).
1236            if let Some(bridge) = &self.async_bridge {
1237                drop(bridge.sender().send(
1238                    crate::services::async_bridge::AsyncMessage::PluginInitScriptLoaded(outcome),
1239                ));
1240            } else {
1241                self.handle_plugin_init_script_loaded(outcome);
1242            }
1243        }
1244    }
1245
1246    #[cfg(feature = "plugins")]
1247    fn spawn_init_script_forwarder(&self, source: String) {
1248        let Some(bridge) = &self.async_bridge else {
1249            return;
1250        };
1251        let Some(rx) = self.plugin_manager.load_plugin_from_source_request(
1252            &source,
1253            crate::init_script::INIT_PLUGIN_NAME,
1254            true,
1255        ) else {
1256            return;
1257        };
1258        let sender = bridge.sender();
1259        std::thread::Builder::new()
1260            .name("plugin-init-forwarder".to_string())
1261            .spawn(move || {
1262                let outcome = match rx.recv() {
1263                    Ok(Ok(())) => crate::services::async_bridge::PluginInitScriptOutcome::Loaded,
1264                    Ok(Err(e)) => crate::services::async_bridge::PluginInitScriptOutcome::Failed {
1265                        message: format!("{e}"),
1266                    },
1267                    Err(e) => crate::services::async_bridge::PluginInitScriptOutcome::Failed {
1268                        message: format!("plugin thread closed: {e}"),
1269                    },
1270                };
1271                drop(sender.send(
1272                    crate::services::async_bridge::AsyncMessage::PluginInitScriptLoaded(outcome),
1273                ));
1274            })
1275            .ok();
1276    }
1277
1278    #[cfg(not(feature = "plugins"))]
1279    fn spawn_init_script_forwarder(&self, _source: String) {}
1280
1281    /// Handle `setSetting(path, value)`. Fire-and-forget: patches Config
1282    /// directly via JSON round-trip. No overlay, no per-plugin tracking,
1283    /// no revert on unload — same model as Neovim/VS Code/Emacs/Sublime.
1284    pub fn handle_set_setting(&mut self, path: String, value: serde_json::Value) {
1285        let mut json = serde_json::to_value(&*self.config).unwrap_or_default();
1286        set_dot_path(&mut json, &path, value);
1287        match serde_json::from_value::<crate::config::Config>(json) {
1288            Ok(new_config) => {
1289                let old_theme = self.config.theme.clone();
1290                self.config = Arc::new(new_config);
1291                if old_theme != self.config.theme {
1292                    if let Some(theme) = self.theme_registry.get_cloned(&self.config.theme) {
1293                        self.theme = theme;
1294                    }
1295                }
1296                *self.keybindings.write().unwrap() =
1297                    crate::input::keybindings::KeybindingResolver::new(&self.config);
1298                self.clipboard.apply_config(&self.config.clipboard);
1299                self.menu_bar_visible = self.config.editor.show_menu_bar;
1300                self.tab_bar_visible = self.config.editor.show_tab_bar;
1301                self.status_bar_visible = self.config.editor.show_status_bar;
1302                self.prompt_line_visible = self.config.editor.show_prompt_line;
1303                #[cfg(feature = "plugins")]
1304                self.update_plugin_state_snapshot();
1305            }
1306            Err(e) => {
1307                self.set_status_message(format!("setSetting({path}): {e}"));
1308            }
1309        }
1310    }
1311
1312    /// Apply the result of one async startup-batch directory load.
1313    /// Mirrors the per-iteration body of the legacy synchronous loop in
1314    /// `with_options`: merge discovered plugins into config, log errors,
1315    /// and panic in debug builds (the legacy behaviour).
1316    pub(crate) fn handle_plugins_dir_loaded(
1317        &mut self,
1318        dir: std::path::PathBuf,
1319        errors: Vec<String>,
1320        discovered_plugins: std::collections::HashMap<String, fresh_core::config::PluginConfig>,
1321    ) {
1322        if !discovered_plugins.is_empty() {
1323            let cfg = std::sync::Arc::make_mut(&mut self.config);
1324            for (name, plugin_config) in discovered_plugins {
1325                cfg.plugins.insert(name, plugin_config);
1326            }
1327        }
1328        if !errors.is_empty() {
1329            for err in &errors {
1330                tracing::error!("TypeScript plugin load error: {}", err);
1331            }
1332            #[cfg(debug_assertions)]
1333            panic!(
1334                "TypeScript plugin loading failed for {:?} with {} error(s): {}",
1335                dir,
1336                errors.len(),
1337                errors.join("; ")
1338            );
1339            #[cfg(not(debug_assertions))]
1340            {
1341                let _ = dir;
1342            }
1343        }
1344    }
1345
1346    /// Apply the declarations harvested at the end of the async startup
1347    /// batch. Mirrors the synchronous `plugin_declarations` +
1348    /// `write_plugin_declarations` pair in `with_options`.
1349    pub(crate) fn handle_plugin_declarations_ready(&self, declarations: Vec<(String, String)>) {
1350        crate::init_script::write_plugin_declarations(&self.dir_context.config_dir, &declarations);
1351    }
1352
1353    /// Apply the result of the async `init.ts` load. Mirrors the trailing
1354    /// `match outcome { ... }` block of the legacy synchronous
1355    /// `load_init_script`.
1356    pub(crate) fn handle_plugin_init_script_loaded(
1357        &mut self,
1358        outcome: crate::services::async_bridge::PluginInitScriptOutcome,
1359    ) {
1360        use crate::init_script::{describe, record_success, InitOutcome};
1361        use crate::services::async_bridge::PluginInitScriptOutcome as O;
1362        let outcome = match outcome {
1363            O::NotFound => InitOutcome::NotFound,
1364            O::Disabled => InitOutcome::Disabled,
1365            O::CrashFused { failures } => InitOutcome::CrashFused { failures },
1366            O::Loaded => {
1367                record_success(&self.dir_context.config_dir);
1368                InitOutcome::Loaded
1369            }
1370            O::Failed { message } => InitOutcome::Failed { message },
1371        };
1372        let summary = describe(&outcome);
1373        match outcome {
1374            InitOutcome::NotFound | InitOutcome::Disabled => tracing::debug!("{}", summary),
1375            InitOutcome::Loaded => tracing::info!("{}", summary),
1376            InitOutcome::CrashFused { .. } | InitOutcome::Failed { .. } => {
1377                tracing::warn!("{}", summary);
1378                self.set_status_message(summary);
1379            }
1380        }
1381    }
1382
1383    /// Fire the `plugins_loaded` hook (design M2, §3.3 phase 2).
1384    pub fn fire_plugins_loaded_hook(&self) {
1385        #[cfg(feature = "plugins")]
1386        if self.plugin_manager.is_active() {
1387            self.plugin_manager.run_hook(
1388                "plugins_loaded",
1389                crate::services::plugins::hooks::HookArgs::PluginsLoaded {},
1390            );
1391        }
1392    }
1393
1394    /// Fire the `ready` hook (design M2, §3.3 phase 3).
1395    pub fn fire_ready_hook(&self) {
1396        #[cfg(feature = "plugins")]
1397        if self.plugin_manager.is_active() {
1398            self.plugin_manager
1399                .run_hook("ready", crate::services::plugins::hooks::HookArgs::Ready {});
1400        }
1401    }
1402
1403    /// Test-only accessor for the current effective config.
1404    #[doc(hidden)]
1405    pub fn config_for_tests(&self) -> &crate::config::Config {
1406        &self.config
1407    }
1408
1409    /// Test-only shim that dispatches an action through the normal path.
1410    #[doc(hidden)]
1411    pub fn dispatch_action_for_tests(&mut self, action: crate::input::keybindings::Action) {
1412        if let Err(e) = self.handle_action(action) {
1413            tracing::warn!("dispatch_action_for_tests: {e}");
1414        }
1415    }
1416
1417    /// Test-only accessor for the Live Grep Resume cache (issue #1796).
1418    #[doc(hidden)]
1419    pub fn live_grep_last_state_for_tests(
1420        &self,
1421    ) -> Option<&crate::services::live_grep_state::LiveGrepLastState> {
1422        self.live_grep_last_state.as_ref()
1423    }
1424
1425    /// Test-only setter for the Live Grep Resume cache.
1426    #[doc(hidden)]
1427    pub fn set_live_grep_last_state_for_tests(
1428        &mut self,
1429        state: Option<crate::services::live_grep_state::LiveGrepLastState>,
1430    ) {
1431        self.live_grep_last_state = state;
1432    }
1433
1434    /// Test-only accessor for the split tree, so layout-shape
1435    /// regression tests can assert on the structure directly.
1436    #[doc(hidden)]
1437    pub fn split_manager_for_tests(&self) -> &crate::view::split::SplitManager {
1438        &self.split_manager
1439    }
1440
1441    /// Test-only accessor for a leaf's `SplitViewState`, so tab-list
1442    /// regression tests can verify which buffers are open in a given
1443    /// pane (the dock should only contain the buffer the user
1444    /// actually asked for, not phantom placeholders).
1445    #[doc(hidden)]
1446    pub fn split_view_state_for_tests(
1447        &self,
1448        leaf: crate::model::event::LeafId,
1449    ) -> Option<&crate::view::split::SplitViewState> {
1450        self.split_view_states.get(&leaf)
1451    }
1452
1453    /// Refresh the plugin-readable keybinding-label snapshot from
1454    /// the current keymap. Call this whenever a plugin is about to
1455    /// surface key hints in its UI (overlay headers, tooltips,
1456    /// menus) so the user's most-recent rebinds are reflected.
1457    ///
1458    /// Cheap — walks every typed `Action` × ~9 contexts; runs in
1459    /// well under a millisecond on this hardware. Cheaper than
1460    /// adding refresh hooks to every keymap-mutation site.
1461    #[cfg(feature = "plugins")]
1462    pub(crate) fn refresh_keybinding_labels_snapshot(&self) {
1463        if let Some(snapshot_handle) = self.plugin_manager.state_snapshot_handle() {
1464            if let Ok(mut snapshot) = snapshot_handle.write() {
1465                populate_builtin_keybinding_labels(&mut snapshot, &self.keybindings);
1466            }
1467        }
1468    }
1469}
1470
1471/// Walk every typed `Action` and the contexts most relevant to UI
1472/// labels (`Normal`, `Prompt`, `Popup`, `FileExplorer`,
1473/// `CompositeBuffer`, `Settings`, `Terminal`), and populate the
1474/// snapshot's `keybinding_labels` map with `<action>\0<context>` →
1475/// formatted label (e.g. `"cycle_live_grep_provider\0prompt"` →
1476/// `"Alt+P"`). The plugin-side `editor.getKeybindingLabel(action,
1477/// mode)` API reads from this map, so plugins displaying hints in
1478/// their UIs (overlay headers, status messages) can look up the
1479/// user's *actual* binding rather than hardcoding a key string.
1480///
1481/// This runs once at startup. If the user later edits their keymap
1482/// without restarting fresh, the labels go stale. That's acceptable
1483/// for v1 — keymap edits today already require a restart for full
1484/// effect; a subsequent commit can wire snapshot refresh into the
1485/// keymap-reload path.
1486#[cfg(feature = "plugins")]
1487fn populate_builtin_keybinding_labels(
1488    snapshot: &mut crate::services::plugins::api::EditorStateSnapshot,
1489    keybindings: &std::sync::Arc<std::sync::RwLock<crate::input::keybindings::KeybindingResolver>>,
1490) {
1491    use crate::input::keybindings::{Action, KeyContext};
1492    let Ok(resolver) = keybindings.read() else {
1493        return;
1494    };
1495    let contexts = [
1496        KeyContext::Normal,
1497        KeyContext::Prompt,
1498        KeyContext::Popup,
1499        KeyContext::Completion,
1500        KeyContext::FileExplorer,
1501        KeyContext::Menu,
1502        KeyContext::Terminal,
1503        KeyContext::Settings,
1504        KeyContext::CompositeBuffer,
1505    ];
1506    // Clear stale built-in entries first so a re-populate after
1507    // the user un-binds an action drops the label rather than
1508    // leaving the old key visible. Entries whose `\0<context>`
1509    // suffix isn't in our list are left alone — those belong to
1510    // plugin-defined buffer modes and have their own
1511    // re-population path in `handle_register_mode`.
1512    let known_suffixes: Vec<String> = contexts
1513        .iter()
1514        .map(|c| format!("\0{}", c.to_when_clause()))
1515        .collect();
1516    snapshot
1517        .keybinding_labels
1518        .retain(|k, _| !known_suffixes.iter().any(|s| k.ends_with(s)));
1519    for action_name in Action::all_action_names() {
1520        for ctx in &contexts {
1521            if let Some(label) = resolver.find_keybinding_for_action(&action_name, ctx.clone()) {
1522                let key = format!("{}\0{}", action_name, ctx.to_when_clause());
1523                snapshot.keybinding_labels.insert(key, label);
1524            }
1525        }
1526    }
1527}