Skip to main content

fresh/app/
editor_init.rs

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