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        tracing::info!("Building default grammar registry...");
76        let start = std::time::Instant::now();
77        let mut grammar_registry = crate::primitives::grammar::GrammarRegistry::defaults_only();
78        // Merge user config so find_by_path respects user globs/filenames
79        // from the very first lookup. `defaults_only` just built the Arc, so
80        // we're the sole owner; get_mut is guaranteed to succeed. Assert
81        // rather than silently drop config — a failure here would leave the
82        // user wondering why their `*.conf → bash` rule doesn't highlight.
83        std::sync::Arc::get_mut(&mut grammar_registry)
84            .expect("defaults_only returned a shared Arc")
85            .apply_language_config(&config.languages);
86        tracing::info!("Default grammar registry built in {:?}", start.elapsed());
87        // Don't start background grammar build here — it's deferred to the
88        // first flush_pending_grammars() call so that plugin-registered grammars
89        // from the first event-loop tick are included in a single build.
90        Self::with_options(
91            config,
92            width,
93            height,
94            working_dir,
95            filesystem,
96            plugins_enabled,
97            true, // enable_embedded_plugins (production: always allow embedded fallback)
98            dir_context,
99            None,
100            color_capability,
101            grammar_registry,
102        )
103    }
104
105    /// Create a new editor for testing with custom backends
106    ///
107    /// By default uses empty grammar registry for fast initialization.
108    /// Pass `Some(registry)` for tests that need syntax highlighting or shebang detection.
109    ///
110    /// `enable_plugins` controls whether the plugin runtime is active at all.
111    /// `enable_embedded_plugins` separately gates the cargo-binstall embedded
112    /// plugins fallback — tests that pre-populate `<config_dir>/plugins/` and
113    /// want exact control over which plugins load can pass `false` here while
114    /// keeping `enable_plugins = true`.
115    #[allow(clippy::too_many_arguments)]
116    pub fn for_test(
117        config: Config,
118        width: u16,
119        height: u16,
120        working_dir: Option<PathBuf>,
121        dir_context: DirectoryContext,
122        color_capability: crate::view::color_support::ColorCapability,
123        filesystem: Arc<dyn FileSystem + Send + Sync>,
124        time_source: Option<SharedTimeSource>,
125        grammar_registry: Option<Arc<crate::primitives::grammar::GrammarRegistry>>,
126        enable_plugins: bool,
127        enable_embedded_plugins: bool,
128    ) -> AnyhowResult<Self> {
129        let mut grammar_registry =
130            grammar_registry.unwrap_or_else(crate::primitives::grammar::GrammarRegistry::empty);
131        // Merge user `[languages]` config into the catalog — production code
132        // does this at startup and again after the background grammar build,
133        // tests need the same so config-declared grammars/extensions resolve
134        // through `find_by_path`. Both call sites that feed into `for_test`
135        // (`HarnessOptions::with_full_grammar_registry` and the default
136        // `GrammarRegistry::empty()`) hand us the sole Arc owner.
137        std::sync::Arc::get_mut(&mut grammar_registry)
138            .expect("grammar registry Arc must be uniquely owned at for_test entry")
139            .apply_language_config(&config.languages);
140        let mut editor = Self::with_options(
141            config,
142            width,
143            height,
144            working_dir,
145            filesystem,
146            enable_plugins,
147            enable_embedded_plugins,
148            dir_context,
149            time_source,
150            color_capability,
151            grammar_registry,
152        )?;
153        // Tests typically have no async_bridge, so the deferred grammar build
154        // would just drain pending_grammars and early-return. Skip it entirely.
155        editor.needs_full_grammar_build = false;
156        Ok(editor)
157    }
158
159    /// Create a new editor with custom options
160    /// This is primarily used for testing with slow or mock backends
161    /// to verify editor behavior under various I/O conditions
162    #[allow(clippy::too_many_arguments)]
163    fn with_options(
164        mut config: Config,
165        width: u16,
166        height: u16,
167        working_dir: Option<PathBuf>,
168        filesystem: Arc<dyn FileSystem + Send + Sync>,
169        enable_plugins: bool,
170        #[cfg_attr(not(feature = "embed-plugins"), allow(unused_variables))]
171        enable_embedded_plugins: bool,
172        dir_context: DirectoryContext,
173        time_source: Option<SharedTimeSource>,
174        color_capability: crate::view::color_support::ColorCapability,
175        grammar_registry: Arc<crate::primitives::grammar::GrammarRegistry>,
176    ) -> AnyhowResult<Self> {
177        // Use provided time_source or default to RealTimeSource
178        let time_source = time_source.unwrap_or_else(RealTimeSource::shared);
179        tracing::info!("Editor::new called with width={}, height={}", width, height);
180
181        // Use provided working_dir or capture from environment
182        let working_dir = working_dir
183            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
184
185        // Canonicalize working_dir to resolve symlinks and normalize path components
186        // This ensures consistent path comparisons throughout the editor
187        let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
188
189        // Load all themes into registry
190        tracing::info!("Loading themes...");
191        let theme_loader = crate::view::theme::ThemeLoader::new(dir_context.themes_dir());
192        // Scan installed packages (language packs + bundles) before plugin loading.
193        // This replaces the JS loadInstalledPackages() — configs, grammars, plugin dirs,
194        // and theme dirs are all collected here and applied synchronously.
195        let scan_result =
196            crate::services::packages::scan_installed_packages(&dir_context.config_dir);
197
198        // Apply package language configs (user config takes priority via or_insert)
199        for (lang_id, lang_config) in &scan_result.language_configs {
200            config
201                .languages
202                .entry(lang_id.clone())
203                .or_insert_with(|| lang_config.clone());
204        }
205
206        // Apply package LSP configs (user config takes priority via or_insert)
207        for (lang_id, lsp_config) in &scan_result.lsp_configs {
208            config
209                .lsp
210                .entry(lang_id.clone())
211                .or_insert_with(|| LspLanguageConfig::Multi(vec![lsp_config.clone()]));
212        }
213
214        let theme_registry = theme_loader.load_all(&scan_result.bundle_theme_dirs);
215        tracing::info!("Themes loaded");
216
217        // Get active theme from registry, falling back to default if not found
218        let theme = theme_registry.get_cloned(&config.theme).unwrap_or_else(|| {
219            tracing::warn!(
220                "Theme '{}' not found, falling back to default theme",
221                config.theme.0
222            );
223            theme_registry
224                .get_cloned(&crate::config::ThemeName(
225                    crate::view::theme::THEME_HIGH_CONTRAST.to_string(),
226                ))
227                .expect("Default theme must exist")
228        });
229
230        // Set terminal cursor color to match theme
231        theme.set_terminal_cursor_color();
232
233        let keybindings = Arc::new(RwLock::new(KeybindingResolver::new(&config)));
234
235        // Create an empty initial buffer
236        let mut buffers = HashMap::new();
237        let mut event_logs = HashMap::new();
238
239        // Buffer IDs start at 1 (not 0) because the plugin API returns 0 to
240        // mean "no active buffer" from getActiveBufferId().  JavaScript treats
241        // 0 as falsy (`if (!bufferId)` would wrongly reject buffer 0), so
242        // using 1-based IDs avoids this entire class of bugs in plugins.
243        let buffer_id = BufferId(1);
244        let mut state = EditorState::new(
245            width,
246            height,
247            config.editor.large_file_threshold_bytes as usize,
248            Arc::clone(&filesystem),
249        );
250        // Configure initial buffer settings from config
251        state
252            .margins
253            .configure_for_line_numbers(config.editor.line_numbers);
254        state.buffer_settings.tab_size = config.editor.tab_size;
255        state.buffer_settings.auto_close = config.editor.auto_close;
256        // Note: line_wrap_enabled is now stored in SplitViewState.viewport
257        tracing::info!("EditorState created for buffer {:?}", buffer_id);
258        buffers.insert(buffer_id, state);
259        event_logs.insert(buffer_id, EventLog::new());
260
261        // Create metadata for the initial empty buffer
262        let mut buffer_metadata = HashMap::new();
263        buffer_metadata.insert(buffer_id, BufferMetadata::new());
264
265        // Initialize LSP manager with current working directory as root
266        let root_uri = types::file_path_to_lsp_uri(&working_dir);
267
268        // Create Tokio runtime for async I/O (LSP, file watching, git, etc.)
269        let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
270            .worker_threads(2) // Small pool for I/O tasks
271            .thread_name("editor-async")
272            .enable_all()
273            .build()
274            .ok();
275
276        // Create async bridge for communication
277        let async_bridge = AsyncBridge::new();
278
279        if tokio_runtime.is_none() {
280            tracing::warn!("Failed to create Tokio runtime - async features disabled");
281        }
282
283        // Create LSP manager with async support
284        let mut lsp = LspManager::new(root_uri);
285
286        // Configure runtime and bridge if available
287        if let Some(ref runtime) = tokio_runtime {
288            lsp.set_runtime(runtime.handle().clone(), async_bridge.clone());
289        }
290
291        // Configure LSP servers from config
292        for (language, lsp_configs) in &config.lsp {
293            lsp.set_language_configs(language.clone(), lsp_configs.as_slice().to_vec());
294        }
295
296        // Configure universal (global) LSP servers — spawned once, shared across languages
297        let universal_servers: Vec<LspServerConfig> = config
298            .universal_lsp
299            .values()
300            .flat_map(|lc| lc.as_slice().to_vec())
301            .filter(|c| c.enabled)
302            .collect();
303        lsp.set_universal_configs(universal_servers);
304
305        // Auto-detect Deno projects: if deno.json or deno.jsonc exists in the
306        // workspace root, override JS/TS LSP to use `deno lsp` (#1191)
307        if working_dir.join("deno.json").exists() || working_dir.join("deno.jsonc").exists() {
308            tracing::info!("Detected Deno project (deno.json found), using deno lsp for JS/TS");
309            let deno_config = LspServerConfig {
310                command: "deno".to_string(),
311                args: vec!["lsp".to_string()],
312                enabled: true,
313                auto_start: false,
314                process_limits: ProcessLimits::default(),
315                initialization_options: Some(serde_json::json!({"enable": true})),
316                ..Default::default()
317            };
318            lsp.set_language_config("javascript".to_string(), deno_config.clone());
319            lsp.set_language_config("typescript".to_string(), deno_config);
320        }
321
322        // Initialize split manager with the initial buffer
323        let split_manager = SplitManager::new(buffer_id);
324
325        // Initialize per-split view state for the initial split
326        let mut split_view_states = HashMap::new();
327        let initial_split_id = split_manager.active_split();
328        let mut initial_view_state = SplitViewState::with_buffer(width, height, buffer_id);
329        initial_view_state.apply_config_defaults(
330            config.editor.line_numbers,
331            config.editor.highlight_current_line,
332            config.editor.line_wrap,
333            config.editor.wrap_indent,
334            config.editor.wrap_column,
335            config.editor.rulers.clone(),
336        );
337        split_view_states.insert(initial_split_id, initial_view_state);
338
339        // Initialize filesystem manager for file explorer
340        let fs_manager = Arc::new(FsManager::new(Arc::clone(&filesystem)));
341
342        // Initialize command registry (always available, used by both plugins and core)
343        let command_registry = Arc::new(RwLock::new(CommandRegistry::new()));
344
345        // Construct the boot-time authority. Per principle 6, the editor
346        // always boots with a local authority and renders immediately;
347        // SSH startup and plugins replace it via `install_authority`
348        // after their async work is done. The supplied `filesystem`
349        // overrides the local default to support tests that mock IO.
350        let authority = crate::services::authority::Authority {
351            filesystem: Arc::clone(&filesystem),
352            ..crate::services::authority::Authority::local()
353        };
354        let process_spawner = Arc::clone(&authority.process_spawner);
355
356        // Initialize Quick Open registry with all providers
357        let mut quick_open_registry = QuickOpenRegistry::new();
358        quick_open_registry.register(Box::new(FileProvider::new(
359            Arc::clone(&filesystem),
360            Arc::clone(&process_spawner),
361            tokio_runtime.as_ref().map(|rt| rt.handle().clone()),
362            Some(async_bridge.sender()),
363        )));
364        quick_open_registry.register(Box::new(CommandProvider::new(
365            Arc::clone(&command_registry),
366            Arc::clone(&keybindings),
367        )));
368        quick_open_registry.register(Box::new(BufferProvider::new()));
369        quick_open_registry.register(Box::new(GotoLineProvider::new()));
370
371        // Build shared theme cache for plugin access
372        let theme_cache = Arc::new(RwLock::new(theme_registry.to_json_map()));
373
374        // Initialize plugin manager (handles both enabled and disabled cases internally)
375        let plugin_manager = PluginManager::new(
376            enable_plugins,
377            Arc::clone(&command_registry),
378            dir_context.clone(),
379            Arc::clone(&theme_cache),
380        );
381
382        // Update the plugin state snapshot with working_dir BEFORE loading plugins
383        // This ensures plugins can call getCwd() correctly during initialization
384        #[cfg(feature = "plugins")]
385        if let Some(snapshot_handle) = plugin_manager.state_snapshot_handle() {
386            let mut snapshot = snapshot_handle.write().unwrap();
387            snapshot.working_dir = working_dir.clone();
388        }
389
390        // Load TypeScript plugins from multiple directories:
391        // 1. Next to the executable (for cargo-dist installations)
392        // 2. From embedded plugins (for cargo-binstall and `cargo run`,
393        //    when embed-plugins feature is enabled)
394        // 3. User plugins directory (~/.config/fresh/plugins)
395        // 4. Package manager installed plugins (~/.config/fresh/plugins/packages/*)
396        if plugin_manager.is_active() {
397            let mut plugin_dirs: Vec<std::path::PathBuf> = vec![];
398
399            // Check next to executable first (for cargo-dist installations)
400            if let Ok(exe_path) = std::env::current_exe() {
401                if let Some(exe_dir) = exe_path.parent() {
402                    let exe_plugin_dir = exe_dir.join("plugins");
403                    if exe_plugin_dir.exists() {
404                        plugin_dirs.push(exe_plugin_dir);
405                    }
406                }
407            }
408
409            // No working-directory `plugins/` check: a user project with a
410            // folder named `plugins/` (e.g. a Vite/Rollup project, a Hugo
411            // site) is not a Fresh plugin source. Bundled plugins for the
412            // dev workflow come in via the embedded fallback below; user
413            // plugins live under `<config_dir>/plugins/`. See issue #1722.
414
415            // If no disk plugins found, try embedded plugins (cargo-binstall builds).
416            // `enable_embedded_plugins` lets tests opt out so they get exactly
417            // the plugin set they pre-populated under `<config_dir>/plugins/`,
418            // without the bundled set leaking in.
419            #[cfg(feature = "embed-plugins")]
420            if enable_embedded_plugins && plugin_dirs.is_empty() {
421                if let Some(embedded_dir) =
422                    crate::services::plugins::embedded::get_embedded_plugins_dir()
423                {
424                    tracing::info!("Using embedded plugins from: {:?}", embedded_dir);
425                    plugin_dirs.push(embedded_dir.clone());
426                }
427            }
428
429            // Always check user config plugins directory (~/.config/fresh/plugins)
430            let user_plugins_dir = dir_context.config_dir.join("plugins");
431            if user_plugins_dir.exists() && !plugin_dirs.contains(&user_plugins_dir) {
432                tracing::info!("Found user plugins directory: {:?}", user_plugins_dir);
433                plugin_dirs.push(user_plugins_dir.clone());
434            }
435
436            // Check for package manager installed plugins (~/.config/fresh/plugins/packages/*)
437            let packages_dir = dir_context.config_dir.join("plugins").join("packages");
438            if packages_dir.exists() {
439                if let Ok(entries) = std::fs::read_dir(&packages_dir) {
440                    for entry in entries.flatten() {
441                        let path = entry.path();
442                        // Skip hidden directories (like .index for registry cache)
443                        if path.is_dir() {
444                            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
445                                if !name.starts_with('.') {
446                                    tracing::info!("Found package manager plugin: {:?}", path);
447                                    plugin_dirs.push(path);
448                                }
449                            }
450                        }
451                    }
452                }
453            }
454
455            // Add bundle plugin directories from package scan
456            for dir in &scan_result.bundle_plugin_dirs {
457                tracing::info!("Found bundle plugin directory: {:?}", dir);
458                plugin_dirs.push(dir.clone());
459            }
460
461            if plugin_dirs.is_empty() {
462                tracing::debug!(
463                    "No plugins directory found next to executable or in working dir: {:?}",
464                    working_dir
465                );
466            }
467
468            // Load from all found plugin directories, respecting config
469            for plugin_dir in plugin_dirs {
470                tracing::info!("Loading TypeScript plugins from: {:?}", plugin_dir);
471                let (errors, discovered_plugins) =
472                    plugin_manager.load_plugins_from_dir_with_config(&plugin_dir, &config.plugins);
473
474                // Merge discovered plugins into config
475                // discovered_plugins already contains the merged config (saved enabled state + discovered path)
476                for (name, plugin_config) in discovered_plugins {
477                    config.plugins.insert(name, plugin_config);
478                }
479
480                if !errors.is_empty() {
481                    for err in &errors {
482                        tracing::error!("TypeScript plugin load error: {}", err);
483                    }
484                    // In debug/test builds, panic to surface plugin loading errors
485                    #[cfg(debug_assertions)]
486                    panic!(
487                        "TypeScript plugin loading failed with {} error(s): {}",
488                        errors.len(),
489                        errors.join("; ")
490                    );
491                }
492            }
493
494            // Collect `.d.ts` emits from every loaded plugin into a
495            // single aggregate under `<config_dir>/types/plugins.d.ts`.
496            // This is what makes `getPluginApi("foo")` typed in the
497            // user's init.ts without a hand-written cast — each plugin
498            // that uses `declare global { interface FreshPluginRegistry }`
499            // contributes its augmentation, and init.ts's tsconfig
500            // picks the aggregate up via `files`.
501            let declarations = plugin_manager.plugin_declarations();
502            crate::init_script::write_plugin_declarations(&dir_context.config_dir, &declarations);
503        }
504
505        // Extract config values before moving config into the struct
506        let file_explorer_width = config.file_explorer.width;
507        let recovery_enabled = config.editor.recovery_enabled;
508        let check_for_updates = config.check_for_updates;
509        let show_menu_bar = config.editor.show_menu_bar;
510        let show_tab_bar = config.editor.show_tab_bar;
511        let show_status_bar = config.editor.show_status_bar;
512        let show_prompt_line = config.editor.show_prompt_line;
513
514        // Start periodic update checker if enabled (also sends daily telemetry)
515        let update_checker = if check_for_updates {
516            tracing::debug!("Update checking enabled, starting periodic checker");
517            Some(
518                crate::services::release_checker::start_periodic_update_check(
519                    crate::services::release_checker::DEFAULT_RELEASES_URL,
520                    time_source.clone(),
521                    dir_context.data_dir.clone(),
522                ),
523            )
524        } else {
525            tracing::debug!("Update checking disabled by config");
526            None
527        };
528
529        // Cache raw user config at startup (to avoid re-reading file every frame)
530        let user_config_raw = Config::read_user_config_raw(&working_dir);
531
532        // Wrap config in Arc and pre-seed the snapshot mirror + JSON cache.
533        // Doing this at construction means the strong count of the live
534        // `config` Arc starts at 2 and stays there: every `Arc::make_mut`
535        // call on `config` is forced to CoW, so no mutation path (direct or
536        // via `config_mut()`) can leave `config_cached_json` referring to
537        // stale memory.
538        let config_arc = Arc::new(config);
539        let config_cached_json =
540            Arc::new(serde_json::to_value(&*config_arc).unwrap_or(serde_json::Value::Null));
541        let config_snapshot_anchor = Arc::clone(&config_arc);
542
543        let mut editor = Editor {
544            buffers,
545            event_logs,
546            next_buffer_id: 2,
547            config: config_arc,
548            config_snapshot_anchor,
549            config_cached_json,
550            user_config_raw: Arc::new(user_config_raw),
551            dir_context: dir_context.clone(),
552            grammar_registry,
553            pending_grammars: scan_result
554                .additional_grammars
555                .iter()
556                .map(|g| PendingGrammar {
557                    language: g.language.clone(),
558                    grammar_path: g.path.to_string_lossy().to_string(),
559                    extensions: g.extensions.clone(),
560                })
561                .collect(),
562            grammar_reload_pending: false,
563            grammar_build_in_progress: false,
564            needs_full_grammar_build: true,
565            streaming_grep_cancellation: None,
566            pending_grammar_callbacks: Vec::new(),
567            theme,
568            theme_registry,
569            theme_cache,
570            ansi_background: None,
571            ansi_background_path: None,
572            background_fade: crate::primitives::ansi_background::DEFAULT_BACKGROUND_FADE,
573            keybindings,
574            clipboard: crate::services::clipboard::Clipboard::new(),
575            should_quit: false,
576            should_detach: false,
577            session_mode: false,
578            software_cursor_only: false,
579            session_name: None,
580            pending_escape_sequences: Vec::new(),
581            restart_with_dir: None,
582            status_message: None,
583            plugin_status_message: None,
584            last_window_title: None,
585            plugin_errors: Vec::new(),
586            prompt: None,
587            terminal_width: width,
588            terminal_height: height,
589            lsp: Some(lsp),
590            buffer_metadata,
591            mode_registry: ModeRegistry::new(),
592            tokio_runtime,
593            async_bridge: Some(async_bridge),
594            split_manager,
595            split_view_states,
596            previous_viewports: HashMap::new(),
597            scroll_sync_manager: ScrollSyncManager::new(),
598            file_explorer: None,
599            preview: None,
600            suppress_position_history_once: false,
601            fs_manager,
602            authority,
603            pending_authority: None,
604            remote_indicator_override: None,
605            local_filesystem: Arc::new(crate::model::filesystem::StdFileSystem),
606            file_explorer_visible: false,
607            file_explorer_sync_in_progress: false,
608            file_explorer_width,
609            pending_file_explorer_show_hidden: None,
610            pending_file_explorer_show_gitignored: None,
611            menu_bar_visible: show_menu_bar,
612            file_explorer_decorations: HashMap::new(),
613            file_explorer_decoration_cache:
614                crate::view::file_tree::FileExplorerDecorationCache::default(),
615            file_explorer_clipboard: None,
616            menu_bar_auto_shown: false,
617            tab_bar_visible: show_tab_bar,
618            status_bar_visible: show_status_bar,
619            prompt_line_visible: show_prompt_line,
620            mouse_enabled: true,
621            same_buffer_scroll_sync: false,
622            mouse_cursor_position: None,
623            gpm_active: false,
624            key_context: KeyContext::Normal,
625            menu_state: crate::view::ui::MenuState::new(dir_context.themes_dir()),
626            menus: crate::config::MenuConfig::translated(),
627            working_dir,
628            position_history: PositionHistory::new(),
629            in_navigation: false,
630            next_lsp_request_id: 0,
631            pending_completion_requests: HashSet::new(),
632            completion_items: None,
633            scheduled_completion_trigger: None,
634            completion_service: crate::services::completion::CompletionService::new(),
635            dabbrev_state: None,
636            pending_goto_definition_request: None,
637            hover: hover::HoverState::default(),
638            pending_references_request: None,
639            pending_references_symbol: String::new(),
640            pending_signature_help_request: None,
641            pending_code_actions_requests: HashSet::new(),
642            pending_code_actions_server_names: HashMap::new(),
643            pending_code_actions: None,
644            pending_inlay_hints_requests: HashMap::new(),
645            pending_folding_range_requests: HashMap::new(),
646            folding_ranges_in_flight: HashMap::new(),
647            folding_ranges_debounce: HashMap::new(),
648            pending_semantic_token_requests: HashMap::new(),
649            semantic_tokens_in_flight: HashMap::new(),
650            pending_semantic_token_range_requests: HashMap::new(),
651            semantic_tokens_range_in_flight: HashMap::new(),
652            semantic_tokens_range_last_request: HashMap::new(),
653            semantic_tokens_range_applied: HashMap::new(),
654            semantic_tokens_full_debounce: HashMap::new(),
655            search_state: None,
656            search_namespace: crate::view::overlay::OverlayNamespace::from_string(
657                "search".to_string(),
658            ),
659            lsp_diagnostic_namespace: crate::view::overlay::OverlayNamespace::from_string(
660                "lsp-diagnostic".to_string(),
661            ),
662            pending_search_range: None,
663            interactive_replace_state: None,
664            mouse_state: MouseState::default(),
665            tab_context_menu: None,
666            file_explorer_context_menu: None,
667            theme_info_popup: None,
668            cached_layout: CachedLayout::default(),
669            command_registry,
670            quick_open_registry,
671            plugin_manager,
672            plugin_dev_workspaces: HashMap::new(),
673            seen_byte_ranges: HashMap::new(),
674            panel_ids: HashMap::new(),
675            buffer_groups: HashMap::new(),
676            buffer_to_group: HashMap::new(),
677            next_buffer_group_id: 0,
678            grouped_subtrees: HashMap::new(),
679            background_process_handles: HashMap::new(),
680            host_process_handles: HashMap::new(),
681            prompt_histories: {
682                // Load prompt histories from disk if available
683                let mut histories = HashMap::new();
684                for history_name in ["search", "replace", "goto_line"] {
685                    let path = dir_context.prompt_history_path(history_name);
686                    let history = crate::input::input_history::InputHistory::load_from_file(&path)
687                        .unwrap_or_else(|e| {
688                            tracing::warn!("Failed to load {} history: {}", history_name, e);
689                            crate::input::input_history::InputHistory::new()
690                        });
691                    histories.insert(history_name.to_string(), history);
692                }
693                histories
694            },
695            pending_async_prompt_callback: None,
696            pending_next_key_callbacks: std::collections::VecDeque::new(),
697            key_capture_active: false,
698            pending_key_capture_buffer: std::collections::VecDeque::new(),
699            goto_line_preview: None,
700            lsp_progress: std::collections::HashMap::new(),
701            lsp_server_statuses: std::collections::HashMap::new(),
702            lsp_window_messages: Vec::new(),
703            lsp_log_messages: Vec::new(),
704            diagnostic_result_ids: HashMap::new(),
705            scheduled_diagnostic_pull: None,
706            scheduled_inlay_hints_request: None,
707            stored_push_diagnostics: HashMap::new(),
708            stored_pull_diagnostics: HashMap::new(),
709            stored_diagnostics: Arc::new(HashMap::new()),
710            stored_folding_ranges: Arc::new(HashMap::new()),
711            event_broadcaster: crate::model::control_event::EventBroadcaster::default(),
712            bookmarks: bookmarks::BookmarkState::default(),
713            search_case_sensitive: true,
714            search_whole_word: false,
715            search_use_regex: false,
716            search_confirm_each: false,
717            macros: macros::MacroState::default(),
718            #[cfg(feature = "plugins")]
719            pending_plugin_actions: Vec::new(),
720            #[cfg(feature = "plugins")]
721            plugin_render_requested: false,
722            chord_state: Vec::new(),
723            user_dismissed_lsp_languages: std::collections::HashSet::new(),
724            auto_start_prompted_languages: std::collections::HashSet::new(),
725            pending_auto_start_prompts: std::collections::HashSet::new(),
726            lsp_auto_prompt_enabled: super::lsp_auto_prompt::default_enabled(),
727            pending_close_buffer: None,
728            auto_revert_enabled: true,
729            last_auto_revert_poll: time_source.now(),
730            last_file_tree_poll: time_source.now(),
731            git_index_resolved: false,
732            file_mod_times: HashMap::new(),
733            dir_mod_times: HashMap::new(),
734            pending_file_poll_rx: None,
735            pending_dir_poll_rx: None,
736            file_rapid_change_counts: HashMap::new(),
737            file_open_state: None,
738            file_browser_layout: None,
739            recovery_service: {
740                let recovery_config = RecoveryConfig {
741                    enabled: recovery_enabled,
742                    ..RecoveryConfig::default()
743                };
744                RecoveryService::with_config_and_dir(recovery_config, dir_context.recovery_dir())
745            },
746            full_redraw_requested: false,
747            suspend_requested: false,
748            time_source: time_source.clone(),
749            last_auto_recovery_save: time_source.now(),
750            last_persistent_auto_save: time_source.now(),
751            active_custom_contexts: HashSet::new(),
752            plugin_global_state: HashMap::new(),
753            editor_mode: None,
754            warning_log: None,
755            status_log_path: None,
756            warning_domains: WarningDomainRegistry::new(),
757            update_checker,
758            terminal_manager: crate::services::terminal::TerminalManager::new(),
759            terminal_buffers: HashMap::new(),
760            terminal_backing_files: HashMap::new(),
761            terminal_log_files: HashMap::new(),
762            ephemeral_terminals: std::collections::HashSet::new(),
763            terminal_mode: false,
764            keyboard_capture: false,
765            terminal_mode_resume: std::collections::HashSet::new(),
766            previous_click_time: None,
767            previous_click_position: None,
768            click_count: 0,
769            settings_state: None,
770            calibration_wizard: None,
771            event_debug: None,
772            keybinding_editor: None,
773            key_translator: crate::input::key_translator::KeyTranslator::load_from_config_dir(
774                &dir_context.config_dir,
775            )
776            .unwrap_or_default(),
777            color_capability,
778            pending_file_opens: Vec::new(),
779            pending_hot_exit_recovery: false,
780            wait_tracking: HashMap::new(),
781            completed_waits: Vec::new(),
782            stdin_stream: stdin_stream::StdinStream::default(),
783            line_scan: line_scan::LineScan::default(),
784            search_scan: search_scan::SearchScan::default(),
785            search_overlay_top_byte: None,
786            review_hunks: Vec::new(),
787            global_popups: crate::view::popup::PopupManager::new(),
788            composite_buffers: HashMap::new(),
789            composite_view_states: HashMap::new(),
790            animations: crate::view::animation::AnimationRunner::new(),
791            previous_cursor_screen_pos: None,
792            cursor_jump_animation: None,
793            pending_vb_animations: Vec::new(),
794        };
795
796        // Apply clipboard configuration
797        editor.clipboard.apply_config(&editor.config.clipboard);
798
799        #[cfg(feature = "plugins")]
800        {
801            editor.update_plugin_state_snapshot();
802            if editor.plugin_manager.is_active() {
803                editor.plugin_manager.run_hook(
804                    "editor_initialized",
805                    crate::services::plugins::hooks::HookArgs::EditorInitialized {},
806                );
807            }
808        }
809
810        Ok(editor)
811    }
812
813    /// Get a reference to the event broadcaster
814    pub fn event_broadcaster(&self) -> &crate::model::control_event::EventBroadcaster {
815        &self.event_broadcaster
816    }
817
818    /// Spawn a background thread to build the full grammar registry
819    /// (embedded grammars, user grammars, language packs, and any plugin-registered grammars).
820    /// Called on the first event-loop tick (via `flush_pending_grammars`) so that
821    /// plugin grammars registered during init are included in a single build.
822    pub(super) fn start_background_grammar_build(
823        &mut self,
824        additional: Vec<crate::primitives::grammar::GrammarSpec>,
825        callback_ids: Vec<fresh_core::api::JsCallbackId>,
826    ) {
827        let Some(bridge) = &self.async_bridge else {
828            return;
829        };
830        self.grammar_build_in_progress = true;
831        let sender = bridge.sender();
832        let config_dir = self.dir_context.config_dir.clone();
833        tracing::info!(
834            "Spawning background grammar build thread ({} plugin grammars)...",
835            additional.len()
836        );
837        std::thread::Builder::new()
838            .name("grammar-build".to_string())
839            .spawn(move || {
840                tracing::info!("[grammar-build] Thread started");
841                let start = std::time::Instant::now();
842                let registry = if additional.is_empty() {
843                    crate::primitives::grammar::GrammarRegistry::for_editor(config_dir)
844                } else {
845                    crate::primitives::grammar::GrammarRegistry::for_editor_with_additional(
846                        config_dir,
847                        &additional,
848                    )
849                };
850                tracing::info!("[grammar-build] Complete in {:?}", start.elapsed());
851                drop(sender.send(
852                    crate::services::async_bridge::AsyncMessage::GrammarRegistryBuilt {
853                        registry,
854                        callback_ids,
855                    },
856                ));
857            })
858            .ok();
859    }
860
861    // =========================================================================
862    // init.ts / runtime-overlay surface (design docs §3–§6)
863    // =========================================================================
864
865    /// Auto-load `~/.config/fresh/init.ts` if present, through the existing
866    /// plugin pipeline under the stable name `crate::init_script::INIT_PLUGIN_NAME`.
867    pub fn load_init_script(&mut self, enabled: bool) {
868        use crate::init_script::{
869            check, decide_load, describe, record_success, refresh_types_scaffolding, CheckSeverity,
870            InitOutcome, LoadDecision,
871        };
872
873        let config_dir = self.dir_context.config_dir.clone();
874
875        if enabled {
876            // Refresh the types mirror from the embedded copy before anything
877            // reads init.ts. Guarantees the declarations the user sees match
878            // the running build — stale types would hide API drift.
879            refresh_types_scaffolding(&config_dir);
880
881            // Re-check init.ts right after the refresh so drift between the
882            // user's script and the current API surface (at least syntax-level
883            // fallout like unterminated blocks from a botched rename) shows up
884            // in the log immediately rather than only at eval time.
885            let report = check(&config_dir);
886            if !report.ok {
887                for d in &report.diagnostics {
888                    let level = match d.severity {
889                        CheckSeverity::Error => "error",
890                        CheckSeverity::Warning => "warning",
891                    };
892                    tracing::warn!(
893                        "init.ts pre-load {level} at {}:{}: {}",
894                        d.line,
895                        d.column,
896                        d.message
897                    );
898                }
899            }
900        }
901
902        let outcome = match decide_load(&config_dir, enabled) {
903            LoadDecision::Skip(outcome) => outcome,
904            LoadDecision::Load { source } => {
905                if !self.plugin_manager.is_active() {
906                    InitOutcome::Failed {
907                        message: "plugin runtime inactive (--no-plugins); init.ts cannot run"
908                            .into(),
909                    }
910                } else {
911                    match self.plugin_manager.load_plugin_from_source(
912                        &source,
913                        crate::init_script::INIT_PLUGIN_NAME,
914                        true,
915                    ) {
916                        Ok(()) => {
917                            record_success(&config_dir);
918                            InitOutcome::Loaded
919                        }
920                        Err(e) => InitOutcome::Failed {
921                            message: format!("{e}"),
922                        },
923                    }
924                }
925            }
926        };
927
928        let summary = describe(&outcome);
929        match outcome {
930            InitOutcome::NotFound | InitOutcome::Disabled => tracing::debug!("{}", summary),
931            InitOutcome::Loaded => tracing::info!("{}", summary),
932            InitOutcome::CrashFused { .. } | InitOutcome::Failed { .. } => {
933                tracing::warn!("{}", summary);
934                self.set_status_message(summary);
935            }
936        }
937    }
938
939    /// Handle `setSetting(path, value)`. Fire-and-forget: patches Config
940    /// directly via JSON round-trip. No overlay, no per-plugin tracking,
941    /// no revert on unload — same model as Neovim/VS Code/Emacs/Sublime.
942    pub fn handle_set_setting(&mut self, path: String, value: serde_json::Value) {
943        let mut json = serde_json::to_value(&*self.config).unwrap_or_default();
944        set_dot_path(&mut json, &path, value);
945        match serde_json::from_value::<crate::config::Config>(json) {
946            Ok(new_config) => {
947                let old_theme = self.config.theme.clone();
948                self.config = Arc::new(new_config);
949                if old_theme != self.config.theme {
950                    if let Some(theme) = self.theme_registry.get_cloned(&self.config.theme) {
951                        self.theme = theme;
952                    }
953                }
954                *self.keybindings.write().unwrap() =
955                    crate::input::keybindings::KeybindingResolver::new(&self.config);
956                self.clipboard.apply_config(&self.config.clipboard);
957                self.menu_bar_visible = self.config.editor.show_menu_bar;
958                self.tab_bar_visible = self.config.editor.show_tab_bar;
959                self.status_bar_visible = self.config.editor.show_status_bar;
960                self.prompt_line_visible = self.config.editor.show_prompt_line;
961                #[cfg(feature = "plugins")]
962                self.update_plugin_state_snapshot();
963            }
964            Err(e) => {
965                self.set_status_message(format!("setSetting({path}): {e}"));
966            }
967        }
968    }
969
970    /// Fire the `plugins_loaded` hook (design M2, §3.3 phase 2).
971    pub fn fire_plugins_loaded_hook(&self) {
972        #[cfg(feature = "plugins")]
973        if self.plugin_manager.is_active() {
974            self.plugin_manager.run_hook(
975                "plugins_loaded",
976                crate::services::plugins::hooks::HookArgs::PluginsLoaded {},
977            );
978        }
979    }
980
981    /// Fire the `ready` hook (design M2, §3.3 phase 3).
982    pub fn fire_ready_hook(&self) {
983        #[cfg(feature = "plugins")]
984        if self.plugin_manager.is_active() {
985            self.plugin_manager
986                .run_hook("ready", crate::services::plugins::hooks::HookArgs::Ready {});
987        }
988    }
989
990    /// Test-only accessor for the current effective config.
991    #[doc(hidden)]
992    pub fn config_for_tests(&self) -> &crate::config::Config {
993        &self.config
994    }
995
996    /// Test-only shim that dispatches an action through the normal path.
997    #[doc(hidden)]
998    pub fn dispatch_action_for_tests(&mut self, action: crate::input::keybindings::Action) {
999        if let Err(e) = self.handle_action(action) {
1000            tracing::warn!("dispatch_action_for_tests: {e}");
1001        }
1002    }
1003}