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