Skip to main content

fresh/app/
plugin_dispatch.rs

1//! Plugin command dispatch and plugin-specific handlers on `Editor`.
2//!
3//! Three clusters previously inline in mod.rs:
4//!
5//! - `update_plugin_state_snapshot` — synchronizes the immutable view of
6//!   editor state plugins observe between commands.
7//! - `handle_plugin_command` — the giant match dispatching every
8//!   PluginCommand variant to a specialized handler. Most arms call
9//!   methods in app/plugin_commands.rs; the rest live below.
10//! - The handle_* family — buffer/path lookups, action execution, plugin
11//!   lifecycle management, and view-control commands callable from
12//!   plugin code.
13
14use std::sync::Arc;
15
16use anyhow::Result as AnyhowResult;
17
18use fresh_core::api::{BufferSavedDiff, JsCallbackId, PluginCommand};
19
20use crate::model::event::{BufferId, LeafId, SplitId};
21use crate::services::async_bridge::AsyncMessage;
22use crate::view::split::SplitViewState;
23
24use super::window::Window;
25use super::{Editor, FloatingWidgetState};
26
27/// Normalize a session path for the plugin API. Sessions reach `WindowInfo`
28/// from two sources — the canonicalized launch session and `create_window_at`'s
29/// raw `PathBuf` — so any byte-level path field (lex sort, equality, …) in a
30/// plugin needs them encoded the same way. On Windows that means resolving
31/// 8.3 short names (`RUNNER~1` → `runneradmin`) and stripping the `\\?\`
32/// verbatim prefix `canonicalize` adds. No-op on non-Windows.
33///
34/// `canonicalize` only works on paths that exist on disk. Worktree session
35/// roots created by the orchestrator often point at directories that haven't
36/// been materialized yet (`<repo>/wt-<name>`), so a naïve canonicalize would
37/// leave them in their original 8.3 short-name form while the launch session
38/// — whose root exists — gets the long-name form, and lex compare inverts
39/// on case (`R` 0x52 < `r` 0x72). Walk up to the deepest existing ancestor,
40/// canonicalize that, then re-attach the missing tail so siblings share the
41/// same prefix encoding regardless of which exist.
42fn normalize_plugin_path(path: std::path::PathBuf) -> std::path::PathBuf {
43    #[cfg(windows)]
44    {
45        let canonical = canonicalize_deepest_existing(&path);
46        let s = canonical.to_string_lossy();
47        if let Some(stripped) = s.strip_prefix(r"\\?\") {
48            return std::path::PathBuf::from(stripped);
49        }
50        return canonical;
51    }
52    #[cfg(not(windows))]
53    path
54}
55
56#[cfg(windows)]
57fn canonicalize_deepest_existing(path: &std::path::Path) -> std::path::PathBuf {
58    if let Ok(c) = path.canonicalize() {
59        return c;
60    }
61    // Walk up to the deepest ancestor that does canonicalize, then re-attach
62    // the components we walked past. Falls back to the raw path if no
63    // ancestor canonicalizes (drive root missing, etc).
64    let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
65    let mut ancestor = path;
66    loop {
67        let Some(parent) = ancestor.parent() else {
68            return path.to_path_buf();
69        };
70        if let Some(name) = ancestor.file_name() {
71            tail.push(name);
72        }
73        if let Ok(c) = parent.canonicalize() {
74            let mut out = c;
75            for name in tail.iter().rev() {
76                out.push(name);
77            }
78            return out;
79        }
80        ancestor = parent;
81    }
82}
83
84/// Returns the byte offset of the start (want_end=false) or end (want_end=true)
85/// of `line` (0-indexed) within `content`. Returns `None` when `line` is out of
86/// range. The "end" position is the byte index of the terminating `\n`; for the
87/// last line with no trailing newline it is `buffer_len`.
88fn buffer_line_byte_offset(
89    content: &str,
90    buffer_len: usize,
91    line: usize,
92    want_end: bool,
93) -> Option<usize> {
94    if !want_end && line == 0 {
95        return Some(0);
96    }
97    let mut current_line = 0usize;
98    for (byte_idx, c) in content.char_indices() {
99        if c == '\n' {
100            if want_end && current_line == line {
101                return Some(byte_idx);
102            }
103            current_line += 1;
104            if !want_end && current_line == line {
105                return Some(byte_idx + 1);
106            }
107        }
108    }
109    if want_end && current_line == line {
110        Some(buffer_len)
111    } else {
112        None
113    }
114}
115
116impl Editor {
117    /// Update the plugin state snapshot with current editor state.
118    ///
119    /// Per-window snapshot population (active buffer, splits, view
120    /// states, cursors, diagnostics, folding ranges, plugin view
121    /// states) lives in [`Window::populate_plugin_state_snapshot`].
122    /// This function adds the editor-wide fields that no single Window
123    /// owns (clipboard, the full `windows` list, the memoized config
124    /// JSON cache, `user_config_raw`, and `plugin_global_state`).
125    #[cfg(feature = "plugins")]
126    pub fn update_plugin_state_snapshot(&mut self) {
127        let Some(snapshot_handle) = self.plugin_manager.read().unwrap().state_snapshot_handle()
128        else {
129            return;
130        };
131        let mut snapshot = snapshot_handle.write().unwrap();
132
133        self.active_window_mut()
134            .populate_plugin_state_snapshot(&mut snapshot);
135
136        // Editor-wide fields below — these reach state outside any
137        // single Window.
138
139        snapshot.clipboard = self.clipboard.get_internal().to_string();
140        snapshot.working_dir = self.working_dir().to_path_buf();
141
142        // Total terminal dimensions (full screen, not the active
143        // split's viewport). Plugins read this via `getScreenSize()`
144        // to size floating overlays against the whole terminal.
145        snapshot.terminal_width = self.terminal_width;
146        snapshot.terminal_height = self.terminal_height;
147
148        // Authority label tracks `Editor::authority` (the active
149        // authority). It can't be sourced from `Window::resources.authority`
150        // because `set_boot_authority` replaces `self.authority` by value
151        // — the per-window resource clones still point at the previous
152        // authority handle. Reading from `Editor` keeps the snapshot in
153        // lockstep with the canonical seat.
154        snapshot.authority_label = self.authority().display_label.clone();
155
156        // Surface the active project's Workspace Trust level so plugins that
157        // run repo-controlled work can gate on it.
158        snapshot.workspace_trust_level = self
159            .authority()
160            .workspace_trust
161            .level()
162            .as_str()
163            .to_string();
164        snapshot.env_active = self.authority().env_provider.is_active();
165
166        // Core is the *only* place that detects which environment a workspace
167        // has. The env-manager plugin reads this resolved result via
168        // `editor.detectedEnv()` rather than probing the filesystem itself.
169        // Empty string ⇒ no env detected.
170        snapshot.detected_env = crate::services::workspace_trust::detect_env(
171            self.working_dir(),
172            &self.config.env.detectors,
173        )
174        .and_then(|d| serde_json::to_string(&d).ok())
175        .unwrap_or_default();
176
177        // Publish the session list so plugins (Orchestrator, etc.)
178        // see updates from createWindow/closeWindow without
179        // a separate notification path. Sorted by id for
180        // deterministic order — `next_window_id` is monotonic
181        // so this is "creation order".
182        let mut session_infos: Vec<fresh_core::api::WindowInfo> = self
183            .windows
184            .values()
185            .map(|s| {
186                let slot = s.plugin_state.get("orchestrator");
187                // Normalise project_path at the API boundary: explicit
188                // non-empty value if the orchestrator recorded one,
189                // otherwise the session's root. Filtering empty strings
190                // is the same guard the plugin used to apply via
191                // `?? root` / `|| root` — now centralised so plugins
192                // can treat `project_path` as an always-set `string`.
193                let project_path = slot
194                    .and_then(|m| m.get("project_path"))
195                    .and_then(|v| v.as_str())
196                    .filter(|p| !p.is_empty())
197                    .map(std::path::PathBuf::from)
198                    .unwrap_or_else(|| s.root.clone());
199                let shared_worktree = slot
200                    .and_then(|m| m.get("shared_worktree"))
201                    .and_then(|v| v.as_bool())
202                    .unwrap_or(false);
203                fresh_core::api::WindowInfo {
204                    id: s.id,
205                    label: s.label.clone(),
206                    root: normalize_plugin_path(s.root.clone()),
207                    project_path: normalize_plugin_path(project_path),
208                    shared_worktree,
209                }
210            })
211            .collect();
212        session_infos.sort_by_key(|s| s.id.0);
213        snapshot.windows = session_infos;
214        snapshot.active_window_id = self.active_window;
215
216        // Reserialize config only when the underlying `Arc<Config>`
217        // pointer has actually moved since the last refresh —
218        // `Arc::ptr_eq` vs `config_snapshot_anchor` is a sound cache
219        // key because the anchor keeps `self.config`'s strong count
220        // at ≥ 2, forcing every `Arc::make_mut` on the editor side
221        // to CoW into a new allocation. On idle (no config mutation),
222        // this branch is skipped entirely and the snapshot update is
223        // a refcount bump.
224        if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
225            let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
226            self.config_cached_json = Arc::new(json);
227            self.config_snapshot_anchor = Arc::clone(&self.config);
228        }
229        snapshot.config = Arc::clone(&self.config_cached_json);
230
231        // Cached raw user config file contents (not merged with defaults).
232        // Lets plugins distinguish user-set from default values.
233        snapshot.user_config = Arc::clone(&self.user_config_raw);
234
235        // Merge plugin global states from Rust-side store.
236        // `or_insert` preserves JS-side write-through entries.
237        for (plugin_name, state_map) in &self.plugin_global_state {
238            let entry = snapshot
239                .plugin_global_states
240                .entry(plugin_name.clone())
241                .or_default();
242            for (key, value) in state_map {
243                entry.entry(key.clone()).or_insert_with(|| value.clone());
244            }
245        }
246    }
247
248    /// Handle a plugin command - dispatches to specialized handlers in plugin_commands module
249    pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
250        match command {
251            // ==================== Text Editing Commands ====================
252            PluginCommand::InsertText {
253                buffer_id,
254                position,
255                text,
256            } => {
257                self.handle_insert_text(buffer_id, position, text);
258            }
259            PluginCommand::DeleteRange { buffer_id, range } => {
260                self.handle_delete_range(buffer_id, range);
261            }
262            PluginCommand::InsertAtCursor { text } => {
263                self.handle_insert_at_cursor(text);
264            }
265            PluginCommand::DeleteSelection => {
266                self.handle_delete_selection();
267            }
268
269            // ==================== Overlay Commands ====================
270            PluginCommand::AddOverlay {
271                buffer_id,
272                namespace,
273                range,
274                options,
275            } => {
276                self.handle_add_overlay(buffer_id, namespace, range, options);
277            }
278            PluginCommand::RemoveOverlay { buffer_id, handle } => {
279                self.handle_remove_overlay(buffer_id, handle);
280            }
281            PluginCommand::ClearAllOverlays { buffer_id } => {
282                self.handle_clear_all_overlays(buffer_id);
283            }
284            PluginCommand::ClearNamespace {
285                buffer_id,
286                namespace,
287            } => {
288                self.handle_clear_namespace(buffer_id, namespace);
289            }
290            PluginCommand::ClearOverlaysInRange {
291                buffer_id,
292                start,
293                end,
294            } => {
295                self.handle_clear_overlays_in_range(buffer_id, start, end);
296            }
297            PluginCommand::ClearOverlaysInRangeForNamespace {
298                buffer_id,
299                namespace,
300                start,
301                end,
302            } => {
303                self.handle_clear_overlays_in_range_for_namespace(buffer_id, namespace, start, end);
304            }
305
306            // ==================== Virtual Text Commands ====================
307            PluginCommand::AddVirtualText {
308                buffer_id,
309                virtual_text_id,
310                position,
311                text,
312                color,
313                use_bg,
314                before,
315            } => {
316                self.handle_add_virtual_text(
317                    buffer_id,
318                    virtual_text_id,
319                    position,
320                    text,
321                    color,
322                    use_bg,
323                    before,
324                );
325            }
326            PluginCommand::AddVirtualTextStyled {
327                buffer_id,
328                virtual_text_id,
329                position,
330                text,
331                fg,
332                bg,
333                bold,
334                italic,
335                before,
336            } => {
337                self.handle_add_virtual_text_styled(
338                    buffer_id,
339                    virtual_text_id,
340                    position,
341                    text,
342                    fg,
343                    bg,
344                    bold,
345                    italic,
346                    before,
347                );
348            }
349            PluginCommand::RemoveVirtualText {
350                buffer_id,
351                virtual_text_id,
352            } => {
353                self.handle_remove_virtual_text(buffer_id, virtual_text_id);
354            }
355            PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
356                self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
357            }
358            PluginCommand::ClearVirtualTexts { buffer_id } => {
359                self.handle_clear_virtual_texts(buffer_id);
360            }
361            PluginCommand::AddVirtualLine {
362                buffer_id,
363                position,
364                text,
365                fg_color,
366                bg_color,
367                above,
368                namespace,
369                priority,
370                gutter_glyph,
371                gutter_color,
372                text_overlays,
373            } => {
374                self.handle_add_virtual_line(
375                    buffer_id,
376                    position,
377                    text,
378                    fg_color,
379                    bg_color,
380                    above,
381                    namespace,
382                    priority,
383                    gutter_glyph,
384                    gutter_color,
385                    text_overlays,
386                );
387            }
388            PluginCommand::ClearVirtualTextNamespace {
389                buffer_id,
390                namespace,
391            } => {
392                self.handle_clear_virtual_text_namespace(buffer_id, namespace);
393            }
394
395            // ==================== Conceal Commands ====================
396            PluginCommand::AddConceal {
397                buffer_id,
398                namespace,
399                start,
400                end,
401                replacement,
402            } => {
403                self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
404            }
405            PluginCommand::ClearConcealNamespace {
406                buffer_id,
407                namespace,
408            } => {
409                self.handle_clear_conceal_namespace(buffer_id, namespace);
410            }
411            PluginCommand::ClearConcealsInRange {
412                buffer_id,
413                start,
414                end,
415            } => {
416                self.handle_clear_conceals_in_range(buffer_id, start, end);
417            }
418
419            PluginCommand::AddFold {
420                buffer_id,
421                start,
422                end,
423                placeholder,
424            } => {
425                self.handle_add_fold(buffer_id, start, end, placeholder);
426            }
427            PluginCommand::ClearFolds { buffer_id } => {
428                self.handle_clear_folds(buffer_id);
429            }
430            PluginCommand::SetFoldingRanges { buffer_id, ranges } => {
431                self.handle_set_folding_ranges(buffer_id, ranges);
432            }
433
434            // ==================== Soft Break Commands ====================
435            PluginCommand::AddSoftBreak {
436                buffer_id,
437                namespace,
438                position,
439                indent,
440            } => {
441                self.handle_add_soft_break(buffer_id, namespace, position, indent);
442            }
443            PluginCommand::ClearSoftBreakNamespace {
444                buffer_id,
445                namespace,
446            } => {
447                self.handle_clear_soft_break_namespace(buffer_id, namespace);
448            }
449            PluginCommand::ClearSoftBreaksInRange {
450                buffer_id,
451                start,
452                end,
453            } => {
454                self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
455            }
456
457            // ==================== Menu Commands ====================
458            PluginCommand::AddMenuItem {
459                menu_label,
460                item,
461                position,
462            } => {
463                self.handle_add_menu_item(menu_label, item, position);
464            }
465            PluginCommand::AddMenu { menu, position } => {
466                self.handle_add_menu(menu, position);
467            }
468            PluginCommand::RemoveMenuItem {
469                menu_label,
470                item_label,
471            } => {
472                self.handle_remove_menu_item(menu_label, item_label);
473            }
474            PluginCommand::RemoveMenu { menu_label } => {
475                self.handle_remove_menu(menu_label);
476            }
477
478            // ==================== Split Commands ====================
479            PluginCommand::FocusSplit { split_id } => {
480                self.handle_focus_split(split_id);
481            }
482            PluginCommand::SetSplitBuffer {
483                split_id,
484                buffer_id,
485            } => {
486                self.handle_set_split_buffer(split_id, buffer_id);
487            }
488            PluginCommand::SetSplitScroll { split_id, top_byte } => {
489                self.handle_set_split_scroll(split_id, top_byte);
490            }
491            PluginCommand::RequestHighlights {
492                buffer_id,
493                range,
494                request_id,
495            } => {
496                self.handle_request_highlights(buffer_id, range, request_id);
497            }
498            PluginCommand::CloseSplit { split_id } => {
499                self.handle_close_split(split_id);
500            }
501            PluginCommand::SetSplitRatio { split_id, ratio } => {
502                self.handle_set_split_ratio(split_id, ratio);
503            }
504            PluginCommand::SetSplitLabel { split_id, label } => {
505                self.handle_set_split_label(split_id, label);
506            }
507            PluginCommand::ClearSplitLabel { split_id } => {
508                self.handle_clear_split_label(split_id);
509            }
510            PluginCommand::GetSplitByLabel { label, request_id } => {
511                self.handle_get_split_by_label(label, request_id);
512            }
513            PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
514                self.handle_distribute_splits_evenly();
515            }
516            PluginCommand::SetBufferCursor {
517                buffer_id,
518                position,
519            } => {
520                self.handle_set_buffer_cursor(buffer_id, position);
521            }
522            PluginCommand::SetBufferShowCursors { buffer_id, show } => {
523                self.handle_set_buffer_show_cursors(buffer_id, show);
524            }
525
526            // ==================== View/Layout Commands ====================
527            PluginCommand::SetLayoutHints {
528                buffer_id,
529                split_id,
530                range: _,
531                hints,
532            } => {
533                self.handle_set_layout_hints(buffer_id, split_id, hints);
534            }
535            PluginCommand::SetLineNumbers { buffer_id, enabled } => {
536                self.handle_set_line_numbers(buffer_id, enabled);
537            }
538            PluginCommand::SetViewMode { buffer_id, mode } => {
539                self.handle_set_view_mode(buffer_id, &mode);
540            }
541            PluginCommand::SetLineWrap {
542                buffer_id,
543                split_id,
544                enabled,
545            } => {
546                self.handle_set_line_wrap(buffer_id, split_id, enabled);
547            }
548            PluginCommand::SubmitViewTransform {
549                buffer_id,
550                split_id,
551                payload,
552            } => {
553                self.handle_submit_view_transform(buffer_id, split_id, payload);
554            }
555            PluginCommand::ClearViewTransform {
556                buffer_id: _,
557                split_id,
558            } => {
559                self.handle_clear_view_transform(split_id);
560            }
561            PluginCommand::SetViewState {
562                buffer_id,
563                key,
564                value,
565            } => {
566                self.handle_set_view_state(buffer_id, key, value);
567            }
568            PluginCommand::SetGlobalState {
569                plugin_name,
570                key,
571                value,
572            } => {
573                self.handle_set_global_state(plugin_name, key, value);
574            }
575            PluginCommand::SetWindowState {
576                plugin_name,
577                key,
578                value,
579            } => {
580                self.handle_set_session_state(plugin_name, key, value);
581            }
582            PluginCommand::RefreshLines { buffer_id } => {
583                self.handle_refresh_lines(buffer_id);
584            }
585            PluginCommand::RefreshAllLines => {
586                self.handle_refresh_all_lines();
587            }
588            PluginCommand::HookCompleted { .. } => {
589                // Sentinel processed in render loop; no-op if encountered elsewhere.
590            }
591            PluginCommand::SetLineIndicator {
592                buffer_id,
593                line,
594                namespace,
595                symbol,
596                color,
597                priority,
598            } => {
599                self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
600            }
601            PluginCommand::SetLineIndicators {
602                buffer_id,
603                lines,
604                namespace,
605                symbol,
606                color,
607                priority,
608            } => {
609                self.handle_set_line_indicators(
610                    buffer_id, lines, namespace, symbol, color, priority,
611                );
612            }
613            PluginCommand::ClearLineIndicators {
614                buffer_id,
615                namespace,
616            } => {
617                self.handle_clear_line_indicators(buffer_id, namespace);
618            }
619            PluginCommand::SetFileExplorerDecorations {
620                namespace,
621                decorations,
622            } => {
623                self.active_window_mut()
624                    .handle_set_file_explorer_decorations(namespace, decorations);
625            }
626            PluginCommand::ClearFileExplorerDecorations { namespace } => {
627                self.active_window_mut()
628                    .handle_clear_file_explorer_decorations(&namespace);
629            }
630            PluginCommand::SetFileExplorerSlots { namespace, slots } => {
631                self.active_window_mut()
632                    .handle_set_file_explorer_slots(namespace, slots);
633            }
634            PluginCommand::ClearFileExplorerSlots { namespace } => {
635                self.active_window_mut()
636                    .handle_clear_file_explorer_slots(&namespace);
637            }
638
639            // ==================== Status/Prompt Commands ====================
640            PluginCommand::SetStatus { message } => {
641                self.handle_set_status(message);
642            }
643            PluginCommand::ApplyTheme { theme_name } => {
644                self.apply_theme(&theme_name);
645            }
646            PluginCommand::OverrideThemeColors { overrides } => {
647                self.handle_override_theme_colors(overrides);
648            }
649            PluginCommand::ReloadConfig => {
650                self.reload_config();
651            }
652            PluginCommand::SetSetting { path, value, .. } => {
653                self.handle_set_setting(path, value);
654            }
655            PluginCommand::AddPluginConfigField {
656                plugin_name,
657                field_name,
658                field_schema,
659            } => {
660                self.handle_add_plugin_config_field(plugin_name, field_name, field_schema);
661            }
662            PluginCommand::ReloadThemes { apply_theme } => {
663                self.handle_reload_themes(apply_theme);
664            }
665            PluginCommand::RegisterGrammar {
666                language,
667                grammar_path,
668                extensions,
669            } => {
670                self.handle_register_grammar(language, grammar_path, extensions);
671            }
672            PluginCommand::RegisterLanguageConfig { language, config } => {
673                self.handle_register_language_config(language, config);
674            }
675            PluginCommand::RegisterLspServer { language, config } => {
676                self.handle_register_lsp_server(language, config);
677            }
678            PluginCommand::ReloadGrammars { callback_id } => {
679                self.handle_reload_grammars(callback_id);
680            }
681            PluginCommand::CancelPrompt => {
682                self.cancel_prompt();
683            }
684            PluginCommand::StartPrompt {
685                label,
686                prompt_type,
687                floating_overlay,
688            } => {
689                self.handle_start_prompt(label, prompt_type, floating_overlay);
690            }
691            PluginCommand::StartPromptWithInitial {
692                label,
693                prompt_type,
694                initial_value,
695                floating_overlay,
696            } => {
697                self.handle_start_prompt_with_initial(
698                    label,
699                    prompt_type,
700                    initial_value,
701                    floating_overlay,
702                );
703            }
704            PluginCommand::StartPromptAsync {
705                label,
706                initial_value,
707                callback_id,
708            } => {
709                self.handle_start_prompt_async(label, initial_value, callback_id);
710            }
711            PluginCommand::AwaitNextKey { callback_id } => {
712                self.handle_await_next_key(callback_id);
713            }
714            PluginCommand::SetKeyCaptureActive { active } => {
715                self.handle_set_key_capture_active(active);
716            }
717            PluginCommand::SetPromptSuggestions {
718                suggestions,
719                selected_index,
720            } => {
721                self.handle_set_prompt_suggestions(suggestions, selected_index);
722            }
723            PluginCommand::SetPromptInputSync { sync } => {
724                self.handle_set_prompt_input_sync(sync);
725            }
726            PluginCommand::SetPromptTitle { title } => {
727                self.handle_set_prompt_title(title);
728            }
729            PluginCommand::SetPromptFooter { footer } => {
730                self.handle_set_prompt_footer(footer);
731            }
732            PluginCommand::SetPromptToolbar { spec } => {
733                self.handle_set_prompt_toolbar(spec);
734            }
735            PluginCommand::ToggleOverlayToolbarWidget { key } => {
736                self.toggle_overlay_toolbar_widget(&key);
737            }
738            PluginCommand::SetPromptStatus { status } => {
739                self.handle_set_prompt_status(status);
740            }
741            PluginCommand::SetPromptSelectedIndex { index } => {
742                self.handle_set_prompt_selected_index(index);
743            }
744
745            // ==================== Session lifecycle ====================
746            // See docs/internal/orchestrator-sessions-design.md.
747            PluginCommand::CreateWindow { root, label } => {
748                self.handle_create_window(root, label);
749            }
750            PluginCommand::CreateWindowWithTerminal {
751                root,
752                label,
753                cwd,
754                command,
755                title,
756                resume,
757                request_id,
758            } => {
759                self.handle_create_window_with_terminal(
760                    root, label, cwd, command, title, resume, request_id,
761                );
762            }
763            PluginCommand::SetActiveWindow { id } => {
764                self.set_active_window(id);
765            }
766            PluginCommand::SetActiveWindowAnimated { id, from_edge } => {
767                self.set_active_window_animated(id, &from_edge);
768            }
769            PluginCommand::SetWindowCycleOrder { ids } => {
770                self.window_cycle_order = if ids.is_empty() { None } else { Some(ids) };
771            }
772            PluginCommand::CloseWindow { id } => {
773                let _ = self.close_window(id);
774            }
775            PluginCommand::PrewarmWindow { id } => {
776                self.prewarm_window(id);
777            }
778
779            // ==================== File watching ====================
780            PluginCommand::WatchPath {
781                path,
782                recursive,
783                request_id,
784            } => {
785                self.handle_watch_path(path, recursive, request_id);
786            }
787            PluginCommand::UnwatchPath { handle } => {
788                self.file_watcher_manager.unwatch(handle);
789            }
790
791            PluginCommand::PreviewWindowInRect { id } => {
792                self.handle_preview_window_in_rect(id);
793            }
794
795            // ==================== Command/Mode Registration ====================
796            PluginCommand::RegisterCommand { command } => {
797                self.handle_register_command(command);
798            }
799            PluginCommand::RegisterStatusBarElement {
800                plugin_name,
801                token_name,
802                title,
803            } => {
804                self.handle_register_status_bar_element(plugin_name, token_name, title);
805            }
806            PluginCommand::SetStatusBarValue {
807                buffer_id,
808                key,
809                value,
810            } => {
811                self.handle_set_status_bar_value(buffer_id, key, value);
812            }
813            PluginCommand::UnregisterCommand { name } => {
814                self.handle_unregister_command(name);
815            }
816            PluginCommand::DefineMode {
817                name,
818                bindings,
819                read_only,
820                allow_text_input,
821                inherit_normal_bindings,
822                plugin_name,
823            } => {
824                self.handle_define_mode(
825                    name,
826                    bindings,
827                    read_only,
828                    allow_text_input,
829                    inherit_normal_bindings,
830                    plugin_name,
831                );
832            }
833
834            // ==================== File/Navigation Commands ====================
835            PluginCommand::OpenFileInBackground { path, window_id } => {
836                self.handle_open_file_in_background_routed(path, window_id);
837            }
838            PluginCommand::OpenFileAtLocation { path, line, column } => {
839                return self.handle_open_file_at_location(path, line, column);
840            }
841            PluginCommand::OpenFileInSplit {
842                split_id,
843                path,
844                line,
845                column,
846            } => {
847                return self.handle_open_file_in_split(split_id, path, line, column);
848            }
849            PluginCommand::ShowBuffer { buffer_id } => {
850                self.handle_show_buffer(buffer_id);
851            }
852            PluginCommand::CloseBuffer { buffer_id } => {
853                self.handle_close_buffer(buffer_id);
854            }
855            PluginCommand::CloseOtherBuffersInSplit {
856                buffer_id,
857                split_id,
858            } => {
859                self.handle_close_other_buffers_in_split(buffer_id, split_id);
860            }
861            PluginCommand::CloseAllBuffersInSplit { split_id } => {
862                self.handle_close_all_buffers_in_split(split_id);
863            }
864            PluginCommand::CloseBuffersToRightInSplit {
865                buffer_id,
866                split_id,
867            } => {
868                self.handle_close_buffers_to_right_in_split(buffer_id, split_id);
869            }
870            PluginCommand::CloseBuffersToLeftInSplit {
871                buffer_id,
872                split_id,
873            } => {
874                self.handle_close_buffers_to_left_in_split(buffer_id, split_id);
875            }
876
877            PluginCommand::MoveTabLeft => {
878                self.handle_move_tab_left();
879            }
880            PluginCommand::MoveTabRight => {
881                self.handle_move_tab_right();
882            }
883
884            // ==================== Animation Commands ====================
885            PluginCommand::StartAnimationArea { id, rect, kind } => {
886                self.handle_start_animation_area(id, rect, kind);
887            }
888            PluginCommand::StartAnimationVirtualBuffer {
889                id,
890                buffer_id,
891                kind,
892            } => {
893                self.handle_start_animation_virtual_buffer(id, buffer_id, kind);
894            }
895            PluginCommand::CancelAnimation { id } => {
896                self.handle_cancel_animation(id);
897            }
898
899            // ==================== LSP Commands ====================
900            PluginCommand::SendLspRequest {
901                language,
902                method,
903                params,
904                request_id,
905            } => {
906                self.handle_send_lsp_request(language, method, params, request_id);
907            }
908
909            // ==================== Clipboard Commands ====================
910            PluginCommand::SetClipboard { text } => {
911                self.handle_set_clipboard(text);
912            }
913
914            // ==================== Async Plugin Commands ====================
915            PluginCommand::SpawnProcess {
916                command,
917                args,
918                cwd,
919                stdout_to,
920                callback_id,
921            } => {
922                self.handle_spawn_process(command, args, cwd, stdout_to, callback_id);
923            }
924
925            PluginCommand::SpawnHostProcess {
926                command,
927                args,
928                cwd,
929                callback_id,
930            } => {
931                self.handle_spawn_host_process(command, args, cwd, callback_id);
932            }
933
934            PluginCommand::KillHostProcess { process_id } => {
935                self.handle_kill_host_process(process_id);
936            }
937
938            PluginCommand::SetAuthority { payload } => {
939                self.handle_set_authority(payload);
940            }
941
942            PluginCommand::AttachRemoteAgent {
943                payload,
944                request_id,
945            } => {
946                self.handle_attach_remote_agent(payload, request_id);
947            }
948
949            PluginCommand::CancelRemoteAttach => {
950                self.cancel_remote_attaches();
951            }
952
953            PluginCommand::ClearAuthority => {
954                self.handle_clear_authority();
955            }
956
957            PluginCommand::SetEnv { snippet, dir } => {
958                self.handle_set_env(snippet, dir);
959            }
960
961            PluginCommand::ClearEnv => {
962                self.handle_clear_env();
963            }
964
965            PluginCommand::SetRemoteIndicatorState { state } => {
966                self.handle_set_remote_indicator_state(state);
967            }
968
969            PluginCommand::ClearRemoteIndicatorState => {
970                self.remote_indicator_override = None;
971            }
972
973            PluginCommand::SpawnProcessWait {
974                process_id,
975                callback_id,
976            } => {
977                self.handle_spawn_process_wait(process_id, callback_id);
978            }
979
980            PluginCommand::Delay {
981                callback_id,
982                duration_ms,
983            } => {
984                self.handle_delay(callback_id, duration_ms);
985            }
986
987            PluginCommand::HttpFetch {
988                url,
989                target_path,
990                callback_id,
991            } => {
992                self.handle_http_fetch(url, target_path, callback_id);
993            }
994
995            PluginCommand::SpawnBackgroundProcess {
996                process_id,
997                command,
998                args,
999                cwd,
1000                callback_id,
1001            } => {
1002                self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
1003            }
1004
1005            PluginCommand::KillBackgroundProcess { process_id } => {
1006                self.handle_kill_background_process(process_id);
1007            }
1008
1009            // ==================== Virtual Buffer Commands (complex, kept inline) ====================
1010            PluginCommand::CreateVirtualBuffer {
1011                name,
1012                mode,
1013                read_only,
1014            } => {
1015                self.handle_create_virtual_buffer(name, mode, read_only);
1016            }
1017            PluginCommand::CreateVirtualBufferWithContent {
1018                name,
1019                mode,
1020                read_only,
1021                entries,
1022                show_line_numbers,
1023                show_cursors,
1024                editing_disabled,
1025                hidden_from_tabs,
1026                request_id,
1027            } => {
1028                self.handle_create_virtual_buffer_with_content(
1029                    name,
1030                    mode,
1031                    read_only,
1032                    entries,
1033                    show_line_numbers,
1034                    show_cursors,
1035                    editing_disabled,
1036                    hidden_from_tabs,
1037                    request_id,
1038                );
1039            }
1040            PluginCommand::CreateVirtualBufferInSplit {
1041                name,
1042                mode,
1043                read_only,
1044                entries,
1045                ratio,
1046                direction,
1047                panel_id,
1048                show_line_numbers,
1049                show_cursors,
1050                editing_disabled,
1051                line_wrap,
1052                before,
1053                role,
1054                request_id,
1055            } => {
1056                self.handle_create_virtual_buffer_in_split(
1057                    name,
1058                    mode,
1059                    read_only,
1060                    entries,
1061                    ratio,
1062                    direction,
1063                    panel_id,
1064                    show_line_numbers,
1065                    show_cursors,
1066                    editing_disabled,
1067                    line_wrap,
1068                    before,
1069                    role,
1070                    request_id,
1071                );
1072            }
1073            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1074                self.handle_set_virtual_buffer_content(buffer_id, entries);
1075            }
1076            PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1077                self.handle_get_text_properties_at_cursor(buffer_id);
1078            }
1079            PluginCommand::CreateVirtualBufferInExistingSplit {
1080                name,
1081                mode,
1082                read_only,
1083                entries,
1084                split_id,
1085                show_line_numbers,
1086                show_cursors,
1087                editing_disabled,
1088                line_wrap,
1089                request_id,
1090            } => {
1091                self.handle_create_virtual_buffer_in_existing_split(
1092                    name,
1093                    mode,
1094                    read_only,
1095                    entries,
1096                    split_id,
1097                    show_line_numbers,
1098                    show_cursors,
1099                    editing_disabled,
1100                    line_wrap,
1101                    request_id,
1102                );
1103            }
1104
1105            // ==================== Context Commands ====================
1106            PluginCommand::SetContext { name, active } => {
1107                self.handle_set_context(name, active);
1108            }
1109
1110            // ==================== Review Diff Commands ====================
1111            PluginCommand::SetReviewDiffHunks { hunks } => {
1112                self.handle_set_review_diff_hunks(hunks);
1113            }
1114
1115            // ==================== Vi Mode Commands ====================
1116            PluginCommand::ExecuteAction { action_name } => {
1117                self.handle_execute_action(action_name);
1118            }
1119            PluginCommand::ExecuteActions { actions } => {
1120                self.handle_execute_actions(actions);
1121            }
1122            PluginCommand::GetBufferText {
1123                buffer_id,
1124                start,
1125                end,
1126                request_id,
1127            } => {
1128                self.handle_get_buffer_text(buffer_id, start, end, request_id);
1129            }
1130            PluginCommand::GetLineStartPosition {
1131                buffer_id,
1132                line,
1133                request_id,
1134            } => {
1135                self.handle_get_line_start_position(buffer_id, line, request_id);
1136            }
1137            PluginCommand::GetLineEndPosition {
1138                buffer_id,
1139                line,
1140                request_id,
1141            } => {
1142                self.handle_get_line_end_position(buffer_id, line, request_id);
1143            }
1144            PluginCommand::GetBufferLineCount {
1145                buffer_id,
1146                request_id,
1147            } => {
1148                self.handle_get_buffer_line_count(buffer_id, request_id);
1149            }
1150            PluginCommand::GetCompositeCursorInfo { request_id } => {
1151                self.handle_get_composite_cursor_info(request_id);
1152            }
1153            PluginCommand::OpenFileStreaming { path, request_id } => {
1154                self.handle_open_file_streaming(path, request_id);
1155            }
1156            PluginCommand::RefreshBufferFromDisk {
1157                buffer_id,
1158                request_id,
1159            } => {
1160                self.handle_refresh_buffer_from_disk(buffer_id, request_id);
1161            }
1162            PluginCommand::SetBufferGroupPanelBuffer {
1163                group_id,
1164                panel_name,
1165                buffer_id,
1166                request_id,
1167            } => {
1168                self.handle_set_buffer_group_panel_buffer(
1169                    group_id, panel_name, buffer_id, request_id,
1170                );
1171            }
1172            PluginCommand::ScrollToLineCenter {
1173                split_id,
1174                buffer_id,
1175                line,
1176            } => {
1177                self.handle_scroll_to_line_center(split_id, buffer_id, line);
1178            }
1179            PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1180                self.handle_scroll_buffer_to_line(buffer_id, line);
1181            }
1182            PluginCommand::SetEditorMode { mode } => {
1183                self.handle_set_editor_mode(mode);
1184            }
1185
1186            // ==================== LSP Helper Commands ====================
1187            PluginCommand::ShowActionPopup {
1188                popup_id,
1189                title,
1190                message,
1191                actions,
1192            } => {
1193                self.handle_show_action_popup(popup_id, title, message, actions);
1194            }
1195
1196            PluginCommand::SetLspMenuContributions {
1197                plugin_id,
1198                language,
1199                items,
1200            } => {
1201                self.handle_set_lsp_menu_contributions(plugin_id, language, items);
1202            }
1203
1204            PluginCommand::DisableLspForLanguage { language } => {
1205                self.handle_disable_lsp_for_language(language);
1206            }
1207
1208            PluginCommand::RestartLspForLanguage { language } => {
1209                self.handle_restart_lsp_for_language(language);
1210            }
1211
1212            PluginCommand::SetLspRootUri { language, uri } => {
1213                self.handle_set_lsp_root_uri(language, uri);
1214            }
1215
1216            // ==================== Scroll Sync Commands ====================
1217            PluginCommand::CreateScrollSyncGroup {
1218                group_id,
1219                left_split,
1220                right_split,
1221            } => {
1222                self.handle_create_scroll_sync_group(group_id, left_split, right_split);
1223            }
1224            PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1225                self.handle_set_scroll_sync_anchors(group_id, anchors);
1226            }
1227            PluginCommand::RemoveScrollSyncGroup { group_id } => {
1228                self.handle_remove_scroll_sync_group(group_id);
1229            }
1230
1231            // ==================== Composite Buffer Commands ====================
1232            PluginCommand::CreateCompositeBuffer {
1233                name,
1234                mode,
1235                layout,
1236                sources,
1237                hunks,
1238                initial_focus_hunk,
1239                request_id,
1240            } => {
1241                self.handle_create_composite_buffer(
1242                    name,
1243                    mode,
1244                    layout,
1245                    sources,
1246                    hunks,
1247                    initial_focus_hunk,
1248                    request_id,
1249                );
1250            }
1251            PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1252                self.handle_update_composite_alignment(buffer_id, hunks);
1253            }
1254            PluginCommand::CloseCompositeBuffer { buffer_id } => {
1255                self.active_window_mut().close_composite_buffer(buffer_id);
1256            }
1257            PluginCommand::FlushLayout => {
1258                self.flush_layout();
1259            }
1260            PluginCommand::CompositeNextHunk { buffer_id } => {
1261                self.handle_composite_next_hunk(buffer_id);
1262            }
1263            PluginCommand::CompositePrevHunk { buffer_id } => {
1264                self.handle_composite_prev_hunk(buffer_id);
1265            }
1266
1267            // ==================== Buffer Groups ====================
1268            PluginCommand::CreateBufferGroup {
1269                name,
1270                mode,
1271                layout_json,
1272                request_id,
1273            } => {
1274                self.handle_create_buffer_group(name, mode, layout_json, request_id);
1275            }
1276            PluginCommand::SetPanelContent {
1277                group_id,
1278                panel_name,
1279                entries,
1280            } => {
1281                self.set_panel_content(group_id, panel_name, entries);
1282            }
1283            PluginCommand::CloseBufferGroup { group_id } => {
1284                self.close_buffer_group(group_id);
1285            }
1286            PluginCommand::FocusPanel {
1287                group_id,
1288                panel_name,
1289            } => {
1290                self.focus_panel(group_id, panel_name);
1291            }
1292
1293            // ==================== File Operations ====================
1294            PluginCommand::SaveBufferToPath { buffer_id, path } => {
1295                self.handle_save_buffer_to_path(buffer_id, path);
1296            }
1297
1298            // ==================== Plugin Management ====================
1299            #[cfg(feature = "plugins")]
1300            PluginCommand::LoadPlugin { path, callback_id } => {
1301                self.handle_load_plugin(path, callback_id);
1302            }
1303            #[cfg(feature = "plugins")]
1304            PluginCommand::UnloadPlugin { name, callback_id } => {
1305                self.handle_unload_plugin(name, callback_id);
1306            }
1307            #[cfg(feature = "plugins")]
1308            PluginCommand::ReloadPlugin { name, callback_id } => {
1309                self.handle_reload_plugin(name, callback_id);
1310            }
1311            #[cfg(feature = "plugins")]
1312            PluginCommand::ListPlugins { callback_id } => {
1313                self.handle_list_plugins(callback_id);
1314            }
1315            // When plugins feature is disabled, these commands are no-ops
1316            #[cfg(not(feature = "plugins"))]
1317            PluginCommand::LoadPlugin { .. }
1318            | PluginCommand::UnloadPlugin { .. }
1319            | PluginCommand::ReloadPlugin { .. }
1320            | PluginCommand::ListPlugins { .. } => {
1321                tracing::warn!("Plugin management commands require the 'plugins' feature");
1322            }
1323
1324            // ==================== Terminal Commands ====================
1325            PluginCommand::CreateTerminal {
1326                cwd,
1327                direction,
1328                ratio,
1329                focus,
1330                persistent,
1331                window_id,
1332                command,
1333                title,
1334                request_id,
1335            } => {
1336                self.handle_create_terminal(
1337                    cwd, direction, ratio, focus, persistent, window_id, command, title, request_id,
1338                );
1339            }
1340
1341            PluginCommand::SendTerminalInput { terminal_id, data } => {
1342                self.handle_send_terminal_input(terminal_id, data);
1343            }
1344
1345            PluginCommand::CloseTerminal { terminal_id } => {
1346                self.handle_close_terminal(terminal_id);
1347            }
1348
1349            PluginCommand::SignalWindow { id, signal } => {
1350                self.handle_signal_window(id, &signal);
1351            }
1352
1353            PluginCommand::GrepProject {
1354                pattern,
1355                fixed_string,
1356                case_sensitive,
1357                max_results,
1358                whole_words,
1359                callback_id,
1360            } => {
1361                self.handle_grep_project(
1362                    pattern,
1363                    fixed_string,
1364                    case_sensitive,
1365                    max_results,
1366                    whole_words,
1367                    callback_id,
1368                );
1369            }
1370
1371            PluginCommand::BeginSearch {
1372                pattern,
1373                fixed_string,
1374                case_sensitive,
1375                max_results,
1376                whole_words,
1377                source_buffer_id,
1378                handle_id,
1379            } => {
1380                self.handle_begin_search(
1381                    pattern,
1382                    fixed_string,
1383                    case_sensitive,
1384                    max_results,
1385                    whole_words,
1386                    source_buffer_id,
1387                    handle_id,
1388                );
1389            }
1390
1391            PluginCommand::ReplaceInBuffer {
1392                file_path,
1393                buffer_id,
1394                matches,
1395                replacement,
1396                callback_id,
1397            } => {
1398                self.handle_replace_in_buffer(
1399                    file_path,
1400                    buffer_id,
1401                    matches,
1402                    replacement,
1403                    callback_id,
1404                );
1405            }
1406
1407            PluginCommand::MountWidgetPanel {
1408                plugin,
1409                panel_id,
1410                buffer_id,
1411                spec,
1412            } => {
1413                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1414                self.handle_mount_widget_panel(key, buffer_id, spec);
1415            }
1416
1417            PluginCommand::UpdateWidgetPanel {
1418                plugin,
1419                panel_id,
1420                spec,
1421            } => {
1422                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1423                self.handle_update_widget_panel(&key, spec);
1424            }
1425
1426            PluginCommand::UnmountWidgetPanel { plugin, panel_id } => {
1427                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1428                self.handle_unmount_widget_panel(&key);
1429            }
1430
1431            PluginCommand::WidgetCommand {
1432                plugin,
1433                panel_id,
1434                action,
1435            } => {
1436                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1437                self.handle_widget_command(&key, action);
1438            }
1439
1440            PluginCommand::WidgetMutate {
1441                plugin,
1442                panel_id,
1443                mutation,
1444            } => {
1445                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1446                self.handle_widget_mutate(&key, mutation);
1447            }
1448
1449            PluginCommand::MountFloatingWidget {
1450                plugin,
1451                panel_id,
1452                spec,
1453                width_pct,
1454                height_pct,
1455                as_dock,
1456                focus_marker,
1457            } => {
1458                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1459                self.handle_mount_floating_widget(
1460                    key,
1461                    spec,
1462                    width_pct,
1463                    height_pct,
1464                    as_dock,
1465                    focus_marker,
1466                );
1467            }
1468
1469            PluginCommand::UpdateFloatingWidget {
1470                plugin,
1471                panel_id,
1472                spec,
1473            } => {
1474                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1475                self.handle_update_floating_widget(&key, spec);
1476            }
1477
1478            PluginCommand::UnmountFloatingWidget { plugin, panel_id } => {
1479                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1480                self.handle_unmount_floating_widget(&key);
1481            }
1482
1483            PluginCommand::FloatingPanelControl {
1484                plugin,
1485                panel_id,
1486                op,
1487                arg,
1488            } => {
1489                let key = crate::widgets::PanelKey::new(plugin, panel_id);
1490                self.handle_floating_panel_control(&key, &op, arg);
1491            }
1492        }
1493        Ok(())
1494    }
1495
1496    // ── Delegated handlers extracted from the dispatch match ─────────────
1497
1498    fn handle_watch_path(&mut self, path: std::path::PathBuf, recursive: bool, request_id: u64) {
1499        let result = if let Some(ref bridge) = self.async_bridge {
1500            self.file_watcher_manager.watch(bridge, &path, recursive)
1501        } else {
1502            Err(
1503                "watchPath: no async bridge — file watching is unavailable in this build"
1504                    .to_string(),
1505            )
1506        };
1507        self.last_watch_response_for_test = Some((request_id, result.clone()));
1508        self.send_plugin_response(fresh_core::api::PluginResponse::WatchPathRegistered {
1509            request_id,
1510            result,
1511        });
1512    }
1513
1514    fn handle_set_env(&mut self, snippet: String, dir: Option<String>) {
1515        // Activation runs repo-controlled code, so it's only honored in
1516        // a Trusted workspace — defense in depth even though the plugin
1517        // already gates on `workspaceTrustLevel()`.
1518        use crate::services::workspace_trust::TrustLevel;
1519        if self.authority().workspace_trust.level() == TrustLevel::Trusted {
1520            self.authority()
1521                .env_provider
1522                .set(snippet, dir.map(std::path::PathBuf::from));
1523            // Re-evaluate already-running tooling under the new env — scoped to
1524            // THIS window only.
1525            self.refresh_active_window_lsp_for_env();
1526        } else {
1527            self.active_window_mut().status_message =
1528                Some("Workspace not trusted — cannot activate environment".to_string());
1529        }
1530    }
1531
1532    fn handle_clear_env(&mut self) {
1533        let was_active = self.authority().env_provider.is_active();
1534        self.authority().env_provider.clear();
1535        if was_active {
1536            self.refresh_active_window_lsp_for_env();
1537        }
1538    }
1539
1540    /// Re-launch the *active window's* already-running language servers so they
1541    /// pick up a just-changed environment (`editor.setEnv` / `clearEnv`),
1542    /// WITHOUT a process-wide editor rebuild.
1543    ///
1544    /// The window's `EnvProvider` is updated in place (see
1545    /// `services::env_provider` — "the plugin sets the recipe in place […] and
1546    /// there is no authority rebuild"), so every *new* spawn — new terminals,
1547    /// servers started later — already inherits the change. Only servers that
1548    /// were spawned under the old env need a re-launch, and only this window's:
1549    /// each `Window` owns its own LSP, terminals, and authority, so a single
1550    /// workspace's env activation must not touch the others. The previous
1551    /// `request_restart` here rebuilt the whole editor — tearing down every
1552    /// other orchestrator session's terminals and closing the dock — which is
1553    /// the "Trust restarted everything" bug. Already-open terminals keep
1554    /// running and only adopt the new env when reopened (matching the env
1555    /// subsystem's lazy, capture-at-spawn model).
1556    fn refresh_active_window_lsp_for_env(&mut self) {
1557        let active_id = self.active_window;
1558        let running: Vec<String> = self
1559            .windows
1560            .get(&active_id)
1561            .map(|w| w.lsp.running_servers())
1562            .unwrap_or_default();
1563        if running.is_empty() {
1564            return;
1565        }
1566        let file_path = self
1567            .active_window()
1568            .buffer_metadata
1569            .get(&self.active_buffer())
1570            .and_then(|meta| meta.file_path().cloned());
1571        for language in &running {
1572            if let Some(w) = self.windows.get_mut(&active_id) {
1573                let _ = w.lsp.manual_restart(language, file_path.as_deref());
1574            }
1575            // Re-send didOpen for this language's buffers so the fresh server
1576            // (now under the new env) sees the open documents.
1577            self.reopen_buffers_for_language(language);
1578        }
1579    }
1580
1581    fn handle_open_file_in_background_routed(
1582        &mut self,
1583        path: std::path::PathBuf,
1584        window_id: Option<fresh_core::WindowId>,
1585    ) {
1586        let route_to_inactive =
1587            window_id.filter(|&id| id != self.active_window && self.windows.contains_key(&id));
1588        if let Some(target) = route_to_inactive {
1589            self.handle_open_file_in_inactive_session(target, path);
1590        } else {
1591            self.handle_open_file_in_background(path);
1592        }
1593    }
1594
1595    // ── Handlers extracted from the dispatch match ───────────────────────
1596
1597    fn handle_set_split_label(&mut self, split_id: SplitId, label: String) {
1598        self.windows
1599            .get_mut(&self.active_window)
1600            .and_then(|w| w.split_manager_mut())
1601            .expect("active window must have a populated split layout")
1602            .set_label(LeafId(split_id), label);
1603    }
1604
1605    fn handle_clear_split_label(&mut self, split_id: SplitId) {
1606        self.windows
1607            .get_mut(&self.active_window)
1608            .and_then(|w| w.split_manager_mut())
1609            .expect("active window must have a populated split layout")
1610            .clear_label(split_id);
1611    }
1612
1613    fn handle_reload_themes(&mut self, apply_theme: Option<String>) {
1614        self.reload_themes();
1615        if let Some(theme_name) = apply_theme {
1616            self.apply_theme(&theme_name);
1617        }
1618    }
1619
1620    fn handle_set_key_capture_active(&mut self, active: bool) {
1621        self.active_window_mut().key_capture_active = active;
1622        if !active {
1623            // Capture window closed; any leftover queued keys were intended
1624            // for the plugin and should not leak into normal dispatch.
1625            self.active_window_mut().pending_key_capture_buffer.clear();
1626        }
1627    }
1628
1629    fn handle_set_prompt_input_sync(&mut self, sync: bool) {
1630        if let Some(prompt) = &mut self.active_window_mut().prompt {
1631            prompt.sync_input_on_navigate = sync;
1632        }
1633    }
1634
1635    fn handle_set_prompt_title(&mut self, title: Vec<fresh_core::api::StyledText>) {
1636        if let Some(prompt) = &mut self.active_window_mut().prompt {
1637            prompt.title = title;
1638        }
1639    }
1640
1641    fn handle_set_prompt_footer(&mut self, footer: Vec<fresh_core::api::StyledText>) {
1642        if let Some(prompt) = &mut self.active_window_mut().prompt {
1643            prompt.footer = footer;
1644        }
1645    }
1646
1647    fn handle_set_prompt_toolbar(&mut self, spec: Option<fresh_core::api::WidgetSpec>) {
1648        if let Some(prompt) = &mut self.active_window_mut().prompt {
1649            prompt.toolbar_widget = spec;
1650        }
1651    }
1652
1653    fn handle_set_prompt_status(&mut self, status: String) {
1654        if let Some(prompt) = &mut self.active_window_mut().prompt {
1655            prompt.status = status;
1656        }
1657    }
1658
1659    fn handle_set_prompt_selected_index(&mut self, index: u32) {
1660        if let Some(prompt) = &mut self.active_window_mut().prompt {
1661            let len = prompt.suggestions.len();
1662            if len > 0 {
1663                prompt.selected_suggestion = Some((index as usize).min(len - 1));
1664            }
1665        }
1666    }
1667
1668    fn handle_create_window(&mut self, root: std::path::PathBuf, label: String) {
1669        if !root.is_absolute() {
1670            tracing::warn!(
1671                "CreateWindow rejected: root must be absolute, got {:?}",
1672                root
1673            );
1674        } else {
1675            let _ = self.create_window_at(root, label);
1676        }
1677    }
1678
1679    fn handle_preview_window_in_rect(&mut self, id: Option<fresh_core::WindowId>) {
1680        // Only honour if the session exists and is not the active one
1681        // (no point previewing the session whose UI is already on screen).
1682        self.preview_window_id = match id {
1683            Some(sid) if sid != self.active_window && self.windows.contains_key(&sid) => Some(sid),
1684            _ => None,
1685        };
1686    }
1687
1688    fn handle_register_status_bar_element(
1689        &mut self,
1690        plugin_name: String,
1691        token_name: String,
1692        title: String,
1693    ) {
1694        if let Err(e) = self.register_status_bar_element(&plugin_name, &token_name, &title) {
1695            tracing::warn!("Failed to register statusbar element: {}", e);
1696        }
1697    }
1698
1699    fn handle_set_status_bar_value(&mut self, buffer_id: u64, key: String, value: String) {
1700        if let Err(e) =
1701            self.set_status_bar_value(fresh_core::BufferId(buffer_id as usize), &key, value)
1702        {
1703            // Plugins compute asynchronously off a lagging state snapshot, so
1704            // the target buffer may have closed — an expected, benign race.
1705            tracing::debug!("Skipped statusbar value for stale buffer: {}", e);
1706        }
1707    }
1708
1709    fn handle_cancel_animation(&mut self, id: u64) {
1710        self.active_window_mut()
1711            .animations
1712            .cancel(crate::view::animation::AnimationId::from_raw(id));
1713    }
1714
1715    fn handle_clear_authority(&mut self) {
1716        tracing::info!("Plugin cleared authority; restoring local");
1717        self.clear_authority();
1718    }
1719
1720    fn handle_set_review_diff_hunks(&mut self, hunks: Vec<fresh_core::api::ReviewHunk>) {
1721        self.active_window_mut().review_hunks = hunks;
1722        tracing::debug!(
1723            "Set {} review hunks",
1724            self.active_window_mut().review_hunks.len()
1725        );
1726    }
1727
1728    fn handle_composite_next_hunk(&mut self, buffer_id: fresh_core::BufferId) {
1729        // Inner group leaf: the Review Diff composite lives in the focused
1730        // group leaf, not the outer active split (see `handle_composite_action`).
1731        let split_id = self.active_window().effective_active_pair().0;
1732        self.active_window_mut()
1733            .composite_next_hunk(split_id, buffer_id);
1734    }
1735
1736    fn handle_composite_prev_hunk(&mut self, buffer_id: fresh_core::BufferId) {
1737        let split_id = self.active_window().effective_active_pair().0;
1738        self.active_window_mut()
1739            .composite_prev_hunk(split_id, buffer_id);
1740    }
1741
1742    // ── Virtual-buffer display configuration ────────────────────────────
1743
1744    /// Apply the three display flags (line numbers, cursor visibility,
1745    /// editing lock) that every `create_virtual_buffer_*` command sets
1746    /// on the newly-created buffer's state.
1747    fn configure_vbuf_display(
1748        &mut self,
1749        buffer_id: crate::model::event::BufferId,
1750        show_line_numbers: bool,
1751        show_cursors: bool,
1752        editing_disabled: bool,
1753    ) {
1754        if let Some(state) = self
1755            .windows
1756            .get_mut(&self.active_window)
1757            .map(|w| &mut w.buffers)
1758            .expect("active window present")
1759            .get_mut(&buffer_id)
1760        {
1761            state.margins.configure_for_line_numbers(show_line_numbers);
1762            state.show_cursors = show_cursors;
1763            state.editing_disabled = editing_disabled;
1764        }
1765    }
1766
1767    // ── Virtual-buffer-in-split sub-paths ───────────────────────────────
1768
1769    /// Utility-dock fast path: a leaf with `dock_leaf`'s role already exists,
1770    /// so attach the new virtual buffer there instead of spawning a new split.
1771    #[allow(clippy::too_many_arguments)]
1772    fn route_vbuf_to_existing_dock(
1773        &mut self,
1774        dock_leaf: crate::model::event::LeafId,
1775        name: String,
1776        mode: String,
1777        read_only: bool,
1778        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1779        panel_id: Option<&str>,
1780        show_line_numbers: bool,
1781        show_cursors: bool,
1782        editing_disabled: bool,
1783        request_id: Option<u64>,
1784    ) {
1785        // Capture the source split *before* create_virtual_buffer tabs the
1786        // new buffer into it; we drop that phantom tab after the dock attach.
1787        let source_split_before_create = self.split_manager().active_split();
1788        let buffer_id =
1789            self.active_window_mut()
1790                .create_virtual_buffer(name.clone(), mode, read_only);
1791        self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
1792        if let Some(pid) = panel_id {
1793            self.panel_ids_mut().insert(pid.to_string(), buffer_id);
1794        }
1795        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1796            tracing::error!("Failed to set virtual buffer content (dock route): {}", e);
1797            return;
1798        }
1799        // Swap the dock leaf's active buffer to the new one and add it as a tab.
1800        self.split_manager_mut().set_active_split(dock_leaf);
1801        self.active_window_mut()
1802            .set_pane_buffer(dock_leaf, buffer_id);
1803        // Drop the phantom tab from the source split.
1804        if dock_leaf != source_split_before_create {
1805            if let Some(source_view_state) = self
1806                .windows
1807                .get_mut(&self.active_window)
1808                .and_then(|w| w.split_view_states_mut())
1809                .expect("active window must have a populated split layout")
1810                .get_mut(&source_split_before_create)
1811            {
1812                source_view_state.remove_buffer(buffer_id);
1813            }
1814        }
1815        if let Some(req_id) = request_id {
1816            let result = fresh_core::api::VirtualBufferResult {
1817                buffer_id: buffer_id.0 as u64,
1818                split_id: Some(dock_leaf.0 .0 as u64),
1819            };
1820            self.plugin_manager.read().unwrap().resolve_callback(
1821                fresh_core::api::JsCallbackId::from(req_id),
1822                serde_json::to_string(&result).unwrap_or_default(),
1823            );
1824        }
1825        tracing::info!(
1826            "Routed virtual buffer '{}' into existing utility dock {:?}",
1827            name,
1828            dock_leaf
1829        );
1830    }
1831
1832    /// Idempotent panel update: `panel_name` already maps to a live buffer,
1833    /// so just refresh its content and focus the split it lives in.
1834    fn update_existing_vbuf_panel(
1835        &mut self,
1836        existing_buffer_id: crate::model::event::BufferId,
1837        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1838        request_id: Option<u64>,
1839        panel_name: &str,
1840    ) {
1841        match self.set_virtual_buffer_content(existing_buffer_id, entries) {
1842            Ok(()) => tracing::info!("Updated existing panel '{}' content", panel_name),
1843            Err(e) => tracing::error!("Failed to update panel content: {}", e),
1844        }
1845        let splits = self.split_manager().splits_for_buffer(existing_buffer_id);
1846        if let Some(&split_id) = splits.first() {
1847            self.split_manager_mut().set_active_split(split_id);
1848            // Route through set_pane_buffer so tree + SVS stay consistent.
1849            self.active_window_mut()
1850                .set_pane_buffer(split_id, existing_buffer_id);
1851            tracing::debug!("Focused split {:?} containing panel buffer", split_id);
1852        }
1853        if let Some(req_id) = request_id {
1854            let result = fresh_core::api::VirtualBufferResult {
1855                buffer_id: existing_buffer_id.0 as u64,
1856                split_id: splits.first().map(|s| s.0 .0 as u64),
1857            };
1858            self.plugin_manager.read().unwrap().resolve_callback(
1859                fresh_core::api::JsCallbackId::from(req_id),
1860                serde_json::to_string(&result).unwrap_or_default(),
1861            );
1862        }
1863    }
1864
1865    // ── Line-position shared implementation ─────────────────────────────
1866
1867    /// Shared implementation for `handle_get_line_start_position` and
1868    /// `handle_get_line_end_position`. When `want_end` is false the byte
1869    /// offset of the line's first character is returned; when true, the
1870    /// byte offset of its terminating newline (or `buffer_len` for the
1871    /// last line without a trailing newline).
1872    fn handle_get_line_position(
1873        &mut self,
1874        buffer_id: crate::model::event::BufferId,
1875        line: u32,
1876        request_id: u64,
1877        want_end: bool,
1878    ) {
1879        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1880        let result = self
1881            .windows
1882            .get_mut(&self.active_window)
1883            .map(|w| &mut w.buffers)
1884            .expect("active window present")
1885            .get_mut(&actual_buffer_id)
1886            .and_then(|state| {
1887                let len = state.buffer.len();
1888                let content = state.get_text_range(0, len);
1889                buffer_line_byte_offset(&content, len, line as usize, want_end)
1890            });
1891        self.resolve_json_callback(request_id, result);
1892    }
1893
1894    /// Save a buffer to a specific file path (for :w filename)
1895    fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1896        if let Some(state) = self
1897            .windows
1898            .get_mut(&self.active_window)
1899            .map(|w| &mut w.buffers)
1900            .expect("active window present")
1901            .get_mut(&buffer_id)
1902        {
1903            // Save to the specified path
1904            match state.buffer.save_to_file(&path) {
1905                Ok(()) => {
1906                    // save_to_file already updates file_path internally via finalize_save
1907                    // Run on-save actions (formatting, etc.)
1908                    if let Err(e) = self.finalize_save(Some(path)) {
1909                        tracing::warn!("Failed to finalize save: {}", e);
1910                    }
1911                    tracing::debug!("Saved buffer {:?} to path", buffer_id);
1912                }
1913                Err(e) => {
1914                    self.handle_set_status(format!("Error saving: {}", e));
1915                    tracing::error!("Failed to save buffer to path: {}", e);
1916                }
1917            }
1918        } else {
1919            self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1920            tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1921        }
1922    }
1923
1924    /// Load a plugin from a file path
1925    #[cfg(feature = "plugins")]
1926    fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1927        let load_result = self.plugin_manager.read().unwrap().load_plugin(&path);
1928        match load_result {
1929            Ok(()) => {
1930                tracing::info!("Loaded plugin from {:?}", path);
1931                self.plugin_manager
1932                    .read()
1933                    .unwrap()
1934                    .resolve_callback(callback_id, "true".to_string());
1935            }
1936            Err(e) => {
1937                tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1938                self.plugin_manager
1939                    .read()
1940                    .unwrap()
1941                    .reject_callback(callback_id, format!("{}", e));
1942            }
1943        }
1944    }
1945
1946    /// Unload a plugin by name
1947    #[cfg(feature = "plugins")]
1948    fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1949        // Drop the write guard before the read lock below (match-scrutinee
1950        // temporaries would otherwise live until end-of-match).
1951        let result = self.plugin_manager.write().unwrap().unload_plugin(&name);
1952        match result {
1953            Ok(()) => {
1954                tracing::info!("Unloaded plugin: {}", name);
1955                if let Ok(mut schemas) = self.plugin_schemas.write() {
1956                    schemas.remove(&name);
1957                }
1958                self.plugin_manager
1959                    .read()
1960                    .unwrap()
1961                    .resolve_callback(callback_id, "true".to_string());
1962            }
1963            Err(e) => {
1964                tracing::error!("Failed to unload plugin '{}': {}", name, e);
1965                self.plugin_manager
1966                    .read()
1967                    .unwrap()
1968                    .reject_callback(callback_id, format!("{}", e));
1969            }
1970        }
1971    }
1972
1973    /// Reload a plugin by name
1974    #[cfg(feature = "plugins")]
1975    fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1976        // Capture the plugin's path before reloading so we can refresh its
1977        // schema sidecar too. `list_plugins` is cheap (one channel
1978        // round-trip).
1979        let path = self
1980            .plugin_manager
1981            .read()
1982            .unwrap()
1983            .list_plugins()
1984            .into_iter()
1985            .find(|p| p.name == name)
1986            .map(|p| p.path);
1987        let _ = path; // schema is now re-registered by plugin code on reload
1988        let reload_result = self.plugin_manager.read().unwrap().reload_plugin(&name);
1989        match reload_result {
1990            Ok(()) => {
1991                tracing::info!("Reloaded plugin: {}", name);
1992                self.plugin_manager
1993                    .read()
1994                    .unwrap()
1995                    .resolve_callback(callback_id, "true".to_string());
1996            }
1997            Err(e) => {
1998                tracing::error!("Failed to reload plugin '{}': {}", name, e);
1999                self.plugin_manager
2000                    .read()
2001                    .unwrap()
2002                    .reject_callback(callback_id, format!("{}", e));
2003            }
2004        }
2005    }
2006
2007    /// List all loaded plugins
2008    #[cfg(feature = "plugins")]
2009    fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
2010        let plugins = self.plugin_manager.read().unwrap().list_plugins();
2011        // Serialize to JSON array of { name, path, enabled }
2012        let json_array: Vec<serde_json::Value> = plugins
2013            .iter()
2014            .map(|p| {
2015                serde_json::json!({
2016                    "name": p.name,
2017                    "path": p.path.to_string_lossy(),
2018                    "enabled": p.enabled
2019                })
2020            })
2021            .collect();
2022        let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
2023        self.plugin_manager
2024            .read()
2025            .unwrap()
2026            .resolve_callback(callback_id, json_str);
2027    }
2028
2029    /// Execute an editor action by name (for vi mode plugin)
2030    fn handle_execute_action(&mut self, action_name: String) {
2031        use crate::input::keybindings::Action;
2032        use std::collections::HashMap;
2033
2034        // Parse the action name into an Action enum
2035        if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
2036            // Execute the action
2037            if let Err(e) = self.handle_action(action) {
2038                tracing::warn!("Failed to execute action '{}': {}", action_name, e);
2039            } else {
2040                tracing::debug!("Executed action: {}", action_name);
2041            }
2042        } else {
2043            tracing::warn!("Unknown action: {}", action_name);
2044        }
2045    }
2046
2047    /// Execute multiple actions in sequence, each with an optional repeat count
2048    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
2049    fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
2050        use crate::input::keybindings::Action;
2051        use std::collections::HashMap;
2052
2053        // Plugins may *request* the trust prompt (`workspace_trust_prompt`,
2054        // which asks the user) but must never *set* the trust level
2055        // themselves. Granting/lowering trust is a user+core decision — the
2056        // same boundary VS Code, JetBrains, and Zed enforce: an extension can
2057        // open the prompt, the user decides. Silently drop any attempt to
2058        // dispatch the level-setting actions through this generic channel.
2059        const PLUGIN_FORBIDDEN_ACTIONS: &[&str] = &[
2060            "workspace_trust_trust",
2061            "workspace_trust_restrict",
2062            "workspace_trust_block",
2063        ];
2064
2065        for action_spec in actions {
2066            if PLUGIN_FORBIDDEN_ACTIONS.contains(&action_spec.action.as_str()) {
2067                tracing::warn!(
2068                    "plugin attempted to set workspace trust via '{}' — denied; \
2069                     plugins may request the prompt (workspace_trust_prompt), not set the level",
2070                    action_spec.action
2071                );
2072                continue;
2073            }
2074            if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
2075                // Execute the action `count` times
2076                for _ in 0..action_spec.count {
2077                    if let Err(e) = self.handle_action(action.clone()) {
2078                        tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
2079                        return; // Stop on first error
2080                    }
2081                }
2082                tracing::debug!(
2083                    "Executed action '{}' {} time(s)",
2084                    action_spec.action,
2085                    action_spec.count
2086                );
2087            } else {
2088                tracing::warn!("Unknown action: {}", action_spec.action);
2089                return; // Stop on unknown action
2090            }
2091        }
2092    }
2093
2094    /// Get text from a buffer range (for vi mode yank operations).
2095    ///
2096    /// See [`clamp_buffer_text_range`] for why the requested range is
2097    /// clamped rather than rejected.
2098    fn handle_get_buffer_text(
2099        &mut self,
2100        buffer_id: BufferId,
2101        start: usize,
2102        end: usize,
2103        request_id: u64,
2104    ) {
2105        let result = if let Some(state) = self
2106            .windows
2107            .get_mut(&self.active_window)
2108            .map(|w| &mut w.buffers)
2109            .expect("active window present")
2110            .get_mut(&buffer_id)
2111        {
2112            // Plugins derive `end` from a snapshot length (see
2113            // `get_buffer_length`) that lags the live buffer, so when the
2114            // buffer shrinks between the length read and this fetch — e.g.
2115            // concurrent edits from the editor and an external process
2116            // rewriting the file on disk — the requested end can briefly
2117            // exceed the live length. Clamp to the current bounds and return
2118            // what's there; the plugin recomputes on the next change event.
2119            let (start, end) = clamp_buffer_text_range(start, end, state.buffer.len());
2120            Ok(state.get_text_range(start, end))
2121        } else {
2122            Err(format!("Buffer {:?} not found", buffer_id))
2123        };
2124
2125        // Resolve the JavaScript Promise callback directly
2126        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2127        match result {
2128            Ok(text) => {
2129                // Serialize text as JSON string
2130                let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
2131                self.plugin_manager
2132                    .read()
2133                    .unwrap()
2134                    .resolve_callback(callback_id, json);
2135            }
2136            Err(error) => {
2137                self.plugin_manager
2138                    .read()
2139                    .unwrap()
2140                    .reject_callback(callback_id, error);
2141            }
2142        }
2143    }
2144
2145    /// Set the global editor mode (for vi mode)
2146    fn handle_set_editor_mode(&mut self, mode: Option<String>) {
2147        self.active_window_mut().editor_mode = mode.clone();
2148        tracing::debug!("Set editor mode: {:?}", mode);
2149    }
2150
2151    /// Normalize a plugin-supplied `BufferId`: treat id 0 as "use the active buffer".
2152    fn resolve_buffer_id(&self, buffer_id: BufferId) -> BufferId {
2153        if buffer_id.0 == 0 {
2154            self.active_buffer()
2155        } else {
2156            buffer_id
2157        }
2158    }
2159
2160    /// Serialize `value` as JSON and resolve `request_id` as a JS Promise callback.
2161    fn resolve_json_callback<T: serde::Serialize>(&mut self, request_id: u64, value: T) {
2162        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2163        let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
2164        self.plugin_manager
2165            .read()
2166            .unwrap()
2167            .resolve_callback(callback_id, json);
2168    }
2169
2170    /// Get the byte offset of the start of a line in the active buffer.
2171    fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2172        self.handle_get_line_position(buffer_id, line, request_id, false);
2173    }
2174
2175    /// Get the byte offset of the end of a line (position of its terminating newline,
2176    /// or `buffer_len` for the last line without a trailing newline).
2177    fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2178        self.handle_get_line_position(buffer_id, line, request_id, true);
2179    }
2180
2181    /// Get the total number of lines in a buffer
2182    fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
2183        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2184
2185        let result = if let Some(state) = self
2186            .windows
2187            .get_mut(&self.active_window)
2188            .map(|w| &mut w.buffers)
2189            .expect("active window present")
2190            .get_mut(&actual_buffer_id)
2191        {
2192            let buffer_len = state.buffer.len();
2193            let content = state.get_text_range(0, buffer_len);
2194            let newlines = content.bytes().filter(|&b| b == b'\n').count();
2195            Some(if content.is_empty() {
2196                1
2197            } else {
2198                newlines + usize::from(!content.ends_with('\n'))
2199            })
2200        } else {
2201            None
2202        };
2203
2204        self.resolve_json_callback(request_id, result);
2205    }
2206
2207    /// Resolve cursor info for the active composite (side-by-side diff)
2208    /// buffer. Returns `null` to the plugin when the active buffer isn't a
2209    /// composite buffer; otherwise an object with the focused pane index,
2210    /// pane count, and the 0-indexed source line shown in each pane on the
2211    /// cursor's aligned row (`null` per-pane where that side is blank).
2212    fn handle_get_composite_cursor_info(&mut self, request_id: u64) {
2213        let info = self.active_window().active_composite_cursor_info();
2214        let value = info.map(|(focused_pane, pane_count, lines)| {
2215            serde_json::json!({
2216                "focusedPane": focused_pane,
2217                "paneCount": pane_count,
2218                "lines": lines,
2219            })
2220        });
2221        self.resolve_json_callback(request_id, value);
2222    }
2223
2224    /// Open `path` as a regular buffer for plugin-driven streaming
2225    /// display. The file is created (empty) if missing.
2226    ///
2227    /// Routes through the same `open_file_no_focus` orchestrator that
2228    /// `editor.openFile` uses, so the buffer gets the full setup
2229    /// (encoding/binary detection, language detection, buffer settings,
2230    /// margin config, per-split BufferViewState defaults). This is
2231    /// critical for things like the scrollbar's visual-row index —
2232    /// bypassing this setup and going straight to `BufferData::Unloaded`
2233    /// breaks `line_count()` and any code that depends on it.
2234    ///
2235    /// Designed for buffers that will be filled by a concurrent
2236    /// `spawnProcess` with `stdoutTo`. Pair with `RefreshBufferFromDisk`
2237    /// to grow the buffer as the file is written; `extend_streaming`
2238    /// (called by that path) counts newlines in the appended region
2239    /// so the buffer's line index stays correct as it grows.
2240    fn handle_open_file_streaming(&mut self, path: std::path::PathBuf, request_id: u64) {
2241        // Ensure the file exists at 0 bytes if missing, so the open
2242        // path has something to load.
2243        if !self.authority().filesystem.exists(&path) {
2244            if let Some(parent) = path.parent() {
2245                if !parent.as_os_str().is_empty() {
2246                    if let Err(e) = std::fs::create_dir_all(parent) {
2247                        tracing::warn!(
2248                            "openFileStreaming: failed to create parent dir {:?}: {}",
2249                            parent,
2250                            e
2251                        );
2252                        self.resolve_json_callback::<Option<u64>>(request_id, None);
2253                        return;
2254                    }
2255                }
2256            }
2257            if let Err(e) = std::fs::write(&path, b"") {
2258                tracing::warn!(
2259                    "openFileStreaming: failed to create empty file at {:?}: {}",
2260                    path,
2261                    e
2262                );
2263                self.resolve_json_callback::<Option<u64>>(request_id, None);
2264                return;
2265            }
2266        }
2267
2268        // Use the same orchestrator that backs `editor.openFile`. This
2269        // ensures the buffer is set up identically to a user-opened
2270        // file (settings, language, view-state defaults, line indexing).
2271        let buffer_id = match self.open_file_no_focus(&path) {
2272            Ok(id) => id,
2273            Err(e) => {
2274                tracing::warn!(
2275                    "openFileStreaming: open_file_no_focus failed for {:?}: {}",
2276                    path,
2277                    e
2278                );
2279                self.resolve_json_callback::<Option<u64>>(request_id, None);
2280                return;
2281            }
2282        };
2283
2284        // Plugin-managed surfaces (typically buffer-group panel
2285        // targets) shouldn't show up in quick-switch / tab strip, and
2286        // shouldn't be auto-reverted on file change — the plugin is
2287        // driving the file's contents itself via `extend_streaming`.
2288        if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2289            meta.hidden_from_tabs = true;
2290            meta.auto_revert_enabled = false;
2291        }
2292        let active_split = self
2293            .windows
2294            .get(&self.active_window)
2295            .and_then(|w| w.buffers.splits())
2296            .map(|(mgr, _)| mgr)
2297            .expect("active window must have a populated split layout")
2298            .active_split();
2299        if let Some(vs) = self
2300            .windows
2301            .get_mut(&self.active_window)
2302            .and_then(|w| w.split_view_states_mut())
2303            .expect("active window must have a populated split layout")
2304            .get_mut(&active_split)
2305        {
2306            use crate::view::split::TabTarget;
2307            vs.open_buffers
2308                .retain(|t| !matches!(t, TabTarget::Buffer(b) if *b == buffer_id));
2309        }
2310
2311        self.resolve_json_callback(request_id, Some(buffer_id.0));
2312    }
2313
2314    /// Re-point a buffer-group's panel at a different buffer id.
2315    /// Delegates to `BufferGroupOps::set_buffer_group_panel_buffer`.
2316    fn handle_set_buffer_group_panel_buffer(
2317        &mut self,
2318        group_id: usize,
2319        panel_name: String,
2320        buffer_id: BufferId,
2321        request_id: u64,
2322    ) {
2323        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2324        let ok = self.set_buffer_group_panel_buffer(group_id, panel_name, actual_buffer_id);
2325        self.resolve_json_callback(request_id, ok);
2326    }
2327
2328    /// Re-stat the file backing `buffer_id` and extend the buffer if
2329    /// the file has grown. No-op if the buffer has no file path or the
2330    /// file didn't grow. Resolves with the new total byte length.
2331    fn handle_refresh_buffer_from_disk(&mut self, buffer_id: BufferId, request_id: u64) {
2332        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2333
2334        let path = self
2335            .windows
2336            .get(&self.active_window)
2337            .and_then(|w| w.buffers.splits())
2338            .map(|(_, _)| ())
2339            .and_then(|_| {
2340                self.windows
2341                    .get(&self.active_window)?
2342                    .buffers
2343                    .get(&actual_buffer_id)?
2344                    .buffer
2345                    .file_path()
2346                    .map(|p| p.to_path_buf())
2347            });
2348
2349        let Some(path) = path else {
2350            // No file path — nothing to refresh.
2351            self.resolve_json_callback::<Option<usize>>(request_id, None);
2352            return;
2353        };
2354
2355        let new_size = match self.authority().filesystem.metadata(&path) {
2356            Ok(m) => m.size as usize,
2357            Err(_) => {
2358                self.resolve_json_callback::<Option<usize>>(request_id, None);
2359                return;
2360            }
2361        };
2362
2363        let new_total = if let Some(state) = self
2364            .windows
2365            .get_mut(&self.active_window)
2366            .map(|w| &mut w.buffers)
2367            .expect("active window present")
2368            .get_mut(&actual_buffer_id)
2369        {
2370            let old = state.buffer.total_bytes();
2371            if new_size > old {
2372                state.buffer.extend_streaming(&path, new_size);
2373            }
2374            state.buffer.total_bytes()
2375        } else {
2376            self.resolve_json_callback::<Option<usize>>(request_id, None);
2377            return;
2378        };
2379
2380        self.resolve_json_callback(request_id, Some(new_total));
2381    }
2382
2383    /// Scroll a split to center a specific line in the viewport
2384    fn handle_scroll_to_line_center(
2385        &mut self,
2386        split_id: SplitId,
2387        buffer_id: BufferId,
2388        line: usize,
2389    ) {
2390        let actual_split_id = if split_id.0 == 0 {
2391            self.windows
2392                .get(&self.active_window)
2393                .and_then(|w| w.buffers.splits())
2394                .map(|(mgr, _)| mgr)
2395                .expect("active window must have a populated split layout")
2396                .active_split()
2397        } else {
2398            LeafId(split_id)
2399        };
2400        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2401
2402        // Get viewport height
2403        let viewport_height = if let Some(view_state) = self
2404            .windows
2405            .get(&self.active_window)
2406            .and_then(|w| w.buffers.splits())
2407            .map(|(_, vs)| vs)
2408            .expect("active window must have a populated split layout")
2409            .get(&actual_split_id)
2410        {
2411            view_state.viewport.height as usize
2412        } else {
2413            return;
2414        };
2415
2416        // Calculate the target line to scroll to (center the requested line)
2417        let lines_above = viewport_height / 2;
2418        let target_line = line.saturating_sub(lines_above);
2419
2420        self.active_window_mut().scroll_split_viewport_to(
2421            actual_buffer_id,
2422            actual_split_id,
2423            target_line,
2424            true,
2425        );
2426    }
2427
2428    /// Scroll every split whose active buffer is `buffer_id` so that
2429    /// `line` is within the viewport. Used by plugin panels (buffer
2430    /// groups) whose plugin-side "selected row" doesn't drive the
2431    /// buffer cursor — after updating the selection, the plugin calls
2432    /// this to bring the selected row into view.
2433    ///
2434    /// Walks both the main split tree's leaves AND the inner leaves of
2435    /// all Grouped subtrees stored in `grouped_subtrees`, because the
2436    /// latter are not represented in `split_manager`'s tree.
2437    fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
2438        if !self
2439            .windows
2440            .get(&self.active_window)
2441            .map(|w| &w.buffers)
2442            .expect("active window present")
2443            .contains_key(&buffer_id)
2444        {
2445            return;
2446        }
2447
2448        // Collect the leaf ids whose active buffer is `buffer_id`.
2449        let mut target_leaves: Vec<LeafId> = Vec::new();
2450
2451        // Main tree: walk its leaves.
2452        for leaf_id in self
2453            .windows
2454            .get(&self.active_window)
2455            .and_then(|w| w.buffers.splits())
2456            .map(|(mgr, _)| mgr)
2457            .expect("active window must have a populated split layout")
2458            .root()
2459            .leaf_split_ids()
2460        {
2461            if let Some(vs) = self
2462                .windows
2463                .get(&self.active_window)
2464                .and_then(|w| w.buffers.splits())
2465                .map(|(_, vs)| vs)
2466                .expect("active window must have a populated split layout")
2467                .get(&leaf_id)
2468            {
2469                if vs.active_buffer == buffer_id {
2470                    target_leaves.push(leaf_id);
2471                }
2472            }
2473        }
2474
2475        // Grouped subtrees: walk each group's inner leaves.
2476        for (_group_leaf_id, node) in self.active_window().grouped_subtrees.iter() {
2477            if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
2478                for inner_leaf in layout.leaf_split_ids() {
2479                    if let Some(vs) = self
2480                        .windows
2481                        .get(&self.active_window)
2482                        .and_then(|w| w.buffers.splits())
2483                        .map(|(_, vs)| vs)
2484                        .expect("active window must have a populated split layout")
2485                        .get(&inner_leaf)
2486                    {
2487                        if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
2488                            target_leaves.push(inner_leaf);
2489                        }
2490                    }
2491                }
2492            }
2493        }
2494
2495        if target_leaves.is_empty() {
2496            return;
2497        }
2498
2499        self.active_window_mut()
2500            .scroll_buffer_to_line_in_splits(buffer_id, &target_leaves, line);
2501    }
2502
2503    fn handle_spawn_host_process(
2504        &mut self,
2505        command: String,
2506        args: Vec<String>,
2507        cwd: Option<String>,
2508        callback_id: JsCallbackId,
2509    ) {
2510        // Bypass the active authority on purpose: this is
2511        // reserved for plugin internals that must run host-side
2512        // work (e.g. `devcontainer up`) before the authority
2513        // they want is even built. Uses the same callback shape
2514        // as `SpawnProcess` so the plugin-facing API is
2515        // symmetric.
2516        //
2517        // Kill handle: we store a oneshot sender in
2518        // `host_process_handles` keyed by the callback id. A
2519        // `KillHostProcess` dispatch sends on it; the spawn
2520        // task's `tokio::select!` then start_kill()s the
2521        // child. This lets a plugin cancel a long-running
2522        // spawn (e.g. "Cancel Startup" on the Remote
2523        // Indicator popup during `devcontainer up`).
2524        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2525            use tokio::io::{AsyncReadExt, BufReader};
2526            use tokio::process::Command as TokioCommand;
2527
2528            let effective_cwd = cwd.or_else(|| {
2529                std::env::current_dir()
2530                    .map(|p| p.to_string_lossy().to_string())
2531                    .ok()
2532            });
2533            let sender = bridge.sender();
2534            let process_id = callback_id.as_u64();
2535
2536            // Workspace Trust gates host spawns too. `spawnHostProcess`
2537            // deliberately bypasses the authority spawner, so the choke-point
2538            // guard never sees it — enforce the level here directly. Blocked
2539            // fails every host spawn; Restricted refuses repo-local
2540            // executables. Without this, Blocked wouldn't actually block
2541            // everything.
2542            if let crate::services::workspace_trust::SpawnDecision::Deny(reason) = self
2543                .authority()
2544                .workspace_trust
2545                .decide(&command, effective_cwd.as_deref())
2546            {
2547                #[allow(clippy::let_underscore_must_use)]
2548                let _ = sender.send(AsyncMessage::PluginProcessOutput {
2549                    process_id,
2550                    stdout: String::new(),
2551                    stderr: reason,
2552                    exit_code: -1,
2553                });
2554                return;
2555            }
2556
2557            let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
2558            self.host_process_handles.insert(process_id, kill_tx);
2559
2560            runtime.spawn(async move {
2561                use crate::services::process_hidden::HideWindow;
2562                let mut cmd = TokioCommand::new(&command);
2563                cmd.args(&args);
2564                cmd.stdout(std::process::Stdio::piped());
2565                cmd.stderr(std::process::Stdio::piped());
2566                cmd.hide_window();
2567                if let Some(ref dir) = effective_cwd {
2568                    cmd.current_dir(dir);
2569                }
2570                let mut child = match cmd.spawn() {
2571                    Ok(c) => c,
2572                    Err(e) => {
2573                        #[allow(clippy::let_underscore_must_use)]
2574                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
2575                            process_id,
2576                            stdout: String::new(),
2577                            stderr: e.to_string(),
2578                            exit_code: -1,
2579                        });
2580                        return;
2581                    }
2582                };
2583
2584                // Take the pipes out of the Child so the
2585                // reader tasks own them; then `child.wait()`
2586                // has exclusive mutable access for the
2587                // kill-or-exit select. Matches the
2588                // fresh-plugin-runtime process.rs pattern.
2589                let stdout_pipe = child.stdout.take();
2590                let stderr_pipe = child.stderr.take();
2591
2592                let stdout_fut = async {
2593                    let mut buf = String::new();
2594                    if let Some(s) = stdout_pipe {
2595                        #[allow(clippy::let_underscore_must_use)]
2596                        let _ = BufReader::new(s).read_to_string(&mut buf).await;
2597                    }
2598                    buf
2599                };
2600                let stderr_fut = async {
2601                    let mut buf = String::new();
2602                    if let Some(s) = stderr_pipe {
2603                        #[allow(clippy::let_underscore_must_use)]
2604                        let _ = BufReader::new(s).read_to_string(&mut buf).await;
2605                    }
2606                    buf
2607                };
2608                let wait_fut = async {
2609                    tokio::select! {
2610                        status = child.wait() => {
2611                            status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
2612                        }
2613                        _ = &mut kill_rx => {
2614                            // Best-effort SIGKILL + reap.
2615                            // Children of the killed
2616                            // process may leak (Q-C2).
2617                            #[allow(clippy::let_underscore_must_use)]
2618                            let _ = child.start_kill();
2619                            child
2620                                .wait()
2621                                .await
2622                                .map(|s| s.code().unwrap_or(-1))
2623                                .unwrap_or(-1)
2624                        }
2625                    }
2626                };
2627                let (stdout, stderr, exit_code) = tokio::join!(stdout_fut, stderr_fut, wait_fut);
2628
2629                #[allow(clippy::let_underscore_must_use)]
2630                let _ = sender.send(AsyncMessage::PluginProcessOutput {
2631                    process_id,
2632                    stdout,
2633                    stderr,
2634                    exit_code,
2635                });
2636            });
2637        } else {
2638            self.plugin_manager
2639                .read()
2640                .unwrap()
2641                .reject_callback(callback_id, "Async runtime not available".to_string());
2642        }
2643    }
2644
2645    fn handle_spawn_background_process(
2646        &mut self,
2647        process_id: u64,
2648        command: String,
2649        args: Vec<String>,
2650        cwd: Option<String>,
2651        callback_id: JsCallbackId,
2652    ) {
2653        // Spawn background process with streaming output via tokio
2654        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2655            use tokio::io::{AsyncBufReadExt, BufReader};
2656            use tokio::process::Command as TokioCommand;
2657
2658            let effective_cwd = cwd.unwrap_or_else(|| {
2659                std::env::current_dir()
2660                    .map(|p| p.to_string_lossy().to_string())
2661                    .unwrap_or_else(|_| ".".to_string())
2662            });
2663
2664            let sender = bridge.sender();
2665            let sender_stdout = sender.clone();
2666            let sender_stderr = sender.clone();
2667            let callback_id_u64 = callback_id.as_u64();
2668
2669            // Receiver may be dropped if editor is shutting down
2670            #[allow(clippy::let_underscore_must_use)]
2671            let handle = runtime.spawn(async move {
2672                use crate::services::process_hidden::HideWindow;
2673                let mut child = match TokioCommand::new(&command)
2674                    .args(&args)
2675                    .current_dir(&effective_cwd)
2676                    .stdout(std::process::Stdio::piped())
2677                    .stderr(std::process::Stdio::piped())
2678                    .hide_window()
2679                    .spawn()
2680                {
2681                    Ok(child) => child,
2682                    Err(e) => {
2683                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2684                            fresh_core::api::PluginAsyncMessage::ProcessExit {
2685                                process_id,
2686                                callback_id: callback_id_u64,
2687                                exit_code: -1,
2688                            },
2689                        ));
2690                        tracing::error!("Failed to spawn background process: {}", e);
2691                        return;
2692                    }
2693                };
2694
2695                // Stream stdout
2696                let stdout = child.stdout.take();
2697                let stderr = child.stderr.take();
2698                let pid = process_id;
2699
2700                // Spawn stdout reader
2701                if let Some(stdout) = stdout {
2702                    let sender = sender_stdout;
2703                    tokio::spawn(async move {
2704                        let reader = BufReader::new(stdout);
2705                        let mut lines = reader.lines();
2706                        while let Ok(Some(line)) = lines.next_line().await {
2707                            let _ =
2708                                sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2709                                    fresh_core::api::PluginAsyncMessage::ProcessStdout {
2710                                        process_id: pid,
2711                                        data: line + "\n",
2712                                    },
2713                                ));
2714                        }
2715                    });
2716                }
2717
2718                // Spawn stderr reader
2719                if let Some(stderr) = stderr {
2720                    let sender = sender_stderr;
2721                    tokio::spawn(async move {
2722                        let reader = BufReader::new(stderr);
2723                        let mut lines = reader.lines();
2724                        while let Ok(Some(line)) = lines.next_line().await {
2725                            let _ =
2726                                sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2727                                    fresh_core::api::PluginAsyncMessage::ProcessStderr {
2728                                        process_id: pid,
2729                                        data: line + "\n",
2730                                    },
2731                                ));
2732                        }
2733                    });
2734                }
2735
2736                // Wait for process to complete
2737                let exit_code = match child.wait().await {
2738                    Ok(status) => status.code().unwrap_or(-1),
2739                    Err(_) => -1,
2740                };
2741
2742                let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2743                    fresh_core::api::PluginAsyncMessage::ProcessExit {
2744                        process_id,
2745                        callback_id: callback_id_u64,
2746                        exit_code,
2747                    },
2748                ));
2749            });
2750
2751            // Store abort handle for potential kill
2752            self.background_process_handles
2753                .insert(process_id, handle.abort_handle());
2754        } else {
2755            // No runtime - reject immediately
2756            self.plugin_manager
2757                .read()
2758                .unwrap()
2759                .reject_callback(callback_id, "Async runtime not available".to_string());
2760        }
2761    }
2762
2763    #[allow(clippy::too_many_arguments)]
2764    fn handle_create_virtual_buffer_with_content(
2765        &mut self,
2766        name: String,
2767        mode: String,
2768        read_only: bool,
2769        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2770        show_line_numbers: bool,
2771        show_cursors: bool,
2772        editing_disabled: bool,
2773        hidden_from_tabs: bool,
2774        request_id: Option<u64>,
2775    ) {
2776        // Hidden-from-tabs buffers (e.g. composite source panes) must NOT be
2777        // attached to the active split or made the active buffer: doing so
2778        // pollutes the main tab bar and, when they're later closed, leaves
2779        // auto-created "[No Name]" tabs behind. Create them detached.
2780        let buffer_id = if hidden_from_tabs {
2781            self.active_window_mut().create_virtual_buffer_detached(
2782                name.clone(),
2783                mode.clone(),
2784                read_only,
2785            )
2786        } else {
2787            self.active_window_mut()
2788                .create_virtual_buffer(name.clone(), mode.clone(), read_only)
2789        };
2790        tracing::info!(
2791            "Created virtual buffer '{}' with mode '{}' (id={:?}, detached={})",
2792            name,
2793            mode,
2794            buffer_id,
2795            hidden_from_tabs
2796        );
2797
2798        // TODO: show_line_numbers is duplicated between EditorState.margins and
2799        // BufferViewState. The renderer reads BufferViewState and overwrites
2800        // margins each frame via configure_for_line_numbers(), making the margin
2801        // setting here effectively write-only. Consider removing the margin call
2802        // and only setting BufferViewState.show_line_numbers.
2803        self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
2804        if !hidden_from_tabs {
2805            let active_split = self.split_manager().active_split();
2806            if let Some(view_state) = self
2807                .windows
2808                .get_mut(&self.active_window)
2809                .and_then(|w| w.split_view_states_mut())
2810                .expect("active window must have a populated split layout")
2811                .get_mut(&active_split)
2812            {
2813                view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2814            }
2815        } else if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2816            meta.hidden_from_tabs = true;
2817        }
2818
2819        // Now set the content
2820        match self.set_virtual_buffer_content(buffer_id, entries) {
2821            Ok(()) => {
2822                tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
2823                // Switch to the new buffer to display it — but only when it's
2824                // attached (a detached hidden buffer must not steal the view).
2825                if !hidden_from_tabs {
2826                    self.set_active_buffer(buffer_id);
2827                    tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
2828                }
2829
2830                // Send response if request_id is present
2831                if let Some(req_id) = request_id {
2832                    tracing::info!(
2833                                "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
2834                                req_id,
2835                                buffer_id
2836                            );
2837                    // createVirtualBuffer returns VirtualBufferResult: { bufferId, splitId }
2838                    let result = fresh_core::api::VirtualBufferResult {
2839                        buffer_id: buffer_id.0 as u64,
2840                        split_id: None,
2841                    };
2842                    self.plugin_manager.read().unwrap().resolve_callback(
2843                        fresh_core::api::JsCallbackId::from(req_id),
2844                        serde_json::to_string(&result).unwrap_or_default(),
2845                    );
2846                    tracing::info!(
2847                        "CreateVirtualBufferWithContent: resolve_callback sent for request_id={}",
2848                        req_id
2849                    );
2850                }
2851            }
2852            Err(e) => {
2853                tracing::error!("Failed to set virtual buffer content: {}", e);
2854            }
2855        }
2856    }
2857
2858    #[allow(clippy::too_many_arguments)]
2859    fn handle_create_virtual_buffer_in_split(
2860        &mut self,
2861        name: String,
2862        mode: String,
2863        read_only: bool,
2864        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2865        ratio: f32,
2866        direction: Option<String>,
2867        panel_id: Option<String>,
2868        show_line_numbers: bool,
2869        show_cursors: bool,
2870        editing_disabled: bool,
2871        line_wrap: Option<bool>,
2872        before: bool,
2873        role: Option<String>,
2874        request_id: Option<u64>,
2875    ) {
2876        // Resolve the role string. Unknown roles are silently dropped
2877        // (forward-compat for plugins targeting newer cores).
2878        let split_role: Option<crate::view::split::SplitRole> = match role.as_deref() {
2879            Some("utility_dock") => Some(crate::view::split::SplitRole::UtilityDock),
2880            _ => None,
2881        };
2882
2883        // Path 1 — Utility-dock fast path (issue #1796 / Section 2 of the design):
2884        // if a leaf with this role already exists, attach the new buffer there
2885        // instead of spawning a fresh split.
2886        if let Some(dock_leaf) = split_role.and_then(|r| self.split_manager().find_leaf_by_role(r))
2887        {
2888            return self.route_vbuf_to_existing_dock(
2889                dock_leaf,
2890                name,
2891                mode,
2892                read_only,
2893                entries,
2894                panel_id.as_deref(),
2895                show_line_numbers,
2896                show_cursors,
2897                editing_disabled,
2898                request_id,
2899            );
2900            // No dock yet — fall through to normal split creation,
2901            // then tag the new leaf with the requested role at the end.
2902        }
2903
2904        // Path 2 — Idempotent panel update: if this panel_id already maps to a
2905        // live buffer, refresh its content and re-focus it.
2906        if let Some(pid) = panel_id.as_deref() {
2907            let maybe_existing = self.panel_ids().get(pid).copied();
2908            if let Some(existing_id) = maybe_existing {
2909                let buffer_alive = self
2910                    .windows
2911                    .get(&self.active_window)
2912                    .map(|w| w.buffers.contains_key(&existing_id))
2913                    .unwrap_or(false);
2914                if buffer_alive {
2915                    return self.update_existing_vbuf_panel(existing_id, entries, request_id, pid);
2916                }
2917                // Buffer no longer exists — remove the stale entry and fall through.
2918                tracing::warn!(
2919                    "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
2920                    pid,
2921                    existing_id
2922                );
2923                self.panel_ids_mut().remove(pid);
2924            }
2925        }
2926
2927        // Path 3 — Fresh split creation.
2928        //
2929        // Capture the source split before creating the buffer —
2930        // `create_virtual_buffer` unconditionally adds the new buffer as a tab
2931        // to the currently active split, which is wrong for a panel that lives
2932        // in its own dedicated split (it would appear in BOTH splits — bug #3).
2933        let source_split_before_create = self.split_manager().active_split();
2934
2935        let buffer_id =
2936            self.active_window_mut()
2937                .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2938        tracing::info!(
2939            "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
2940            name,
2941            mode,
2942            buffer_id
2943        );
2944
2945        self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
2946
2947        if let Some(pid) = panel_id {
2948            self.panel_ids_mut().insert(pid, buffer_id);
2949        }
2950
2951        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2952            tracing::error!("Failed to set virtual buffer content: {}", e);
2953            return;
2954        }
2955
2956        let split_dir = match direction.as_deref() {
2957            Some("vertical") => crate::model::event::SplitDirection::Vertical,
2958            _ => crate::model::event::SplitDirection::Horizontal,
2959        };
2960
2961        // When the caller requested `role = "utility_dock"` but no dock leaf
2962        // existed yet (we fell through the fast path above), split at the
2963        // *root* so the dock spans the full width — splitting the active leaf
2964        // would nest it under whichever pane was focused.
2965        let split_result = if split_role == Some(crate::view::split::SplitRole::UtilityDock) {
2966            self.split_manager_mut()
2967                .split_root_positioned(split_dir, buffer_id, ratio, before)
2968        } else {
2969            self.split_manager_mut()
2970                .split_active_positioned(split_dir, buffer_id, ratio, before)
2971        };
2972
2973        let created_split_id = match split_result {
2974            Ok(new_split_id) => {
2975                // The buffer now lives in its own split — drop its phantom tab
2976                // from the source split (bug #3). Only when the splits differ;
2977                // otherwise we'd leave the buffer with no display.
2978                if new_split_id != source_split_before_create {
2979                    if let Some(src_vs) = self
2980                        .windows
2981                        .get_mut(&self.active_window)
2982                        .and_then(|w| w.split_view_states_mut())
2983                        .expect("active window must have a populated split layout")
2984                        .get_mut(&source_split_before_create)
2985                    {
2986                        src_vs.remove_buffer(buffer_id);
2987                    }
2988                }
2989
2990                let mut view_state = SplitViewState::with_buffer(
2991                    self.terminal_width,
2992                    self.terminal_height,
2993                    buffer_id,
2994                );
2995                view_state.apply_config_defaults(
2996                    self.config.editor.line_numbers,
2997                    self.config.editor.highlight_current_line,
2998                    line_wrap.unwrap_or_else(|| {
2999                        self.active_window().resolve_line_wrap_for_buffer(buffer_id)
3000                    }),
3001                    self.config.editor.wrap_indent,
3002                    self.active_window()
3003                        .resolve_wrap_column_for_buffer(buffer_id),
3004                    self.config.editor.rulers.clone(),
3005                    self.config.editor.scroll_offset,
3006                );
3007                view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
3008                self.windows
3009                    .get_mut(&self.active_window)
3010                    .and_then(|w| w.split_view_states_mut())
3011                    .expect("active window must have a populated split layout")
3012                    .insert(new_split_id, view_state);
3013
3014                self.split_manager_mut().set_active_split(new_split_id);
3015
3016                // Tag the new leaf with the requested role so the next
3017                // utility-dock open lands here. Clear any stale role first
3018                // to maintain the one-leaf-per-role invariant.
3019                if let Some(target_role) = split_role {
3020                    self.split_manager_mut().clear_role(target_role);
3021                    self.split_manager_mut()
3022                        .set_leaf_role(new_split_id, Some(target_role));
3023                    tracing::info!(
3024                        "Tagged new dock leaf {:?} with role {:?}",
3025                        new_split_id,
3026                        target_role
3027                    );
3028                }
3029
3030                tracing::info!(
3031                    "Created {:?} split with virtual buffer {:?}",
3032                    split_dir,
3033                    buffer_id
3034                );
3035                Some(new_split_id)
3036            }
3037            Err(e) => {
3038                tracing::error!("Failed to create split: {}", e);
3039                self.set_active_buffer(buffer_id);
3040                None
3041            }
3042        };
3043
3044        if let Some(req_id) = request_id {
3045            tracing::trace!(
3046                "CreateVirtualBufferInSplit: resolving callback for request_id={}, \
3047                 buffer_id={:?}, split_id={:?}",
3048                req_id,
3049                buffer_id,
3050                created_split_id
3051            );
3052            let result = fresh_core::api::VirtualBufferResult {
3053                buffer_id: buffer_id.0 as u64,
3054                split_id: created_split_id.map(|s| s.0 .0 as u64),
3055            };
3056            self.plugin_manager.read().unwrap().resolve_callback(
3057                fresh_core::api::JsCallbackId::from(req_id),
3058                serde_json::to_string(&result).unwrap_or_default(),
3059            );
3060        }
3061    }
3062
3063    #[allow(clippy::too_many_arguments)]
3064    fn handle_create_virtual_buffer_in_existing_split(
3065        &mut self,
3066        name: String,
3067        mode: String,
3068        read_only: bool,
3069        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3070        split_id: SplitId,
3071        show_line_numbers: bool,
3072        show_cursors: bool,
3073        editing_disabled: bool,
3074        line_wrap: Option<bool>,
3075        request_id: Option<u64>,
3076    ) {
3077        // Create the virtual buffer
3078        let buffer_id =
3079            self.active_window_mut()
3080                .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3081        tracing::info!(
3082            "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
3083            name,
3084            mode,
3085            split_id,
3086            buffer_id
3087        );
3088
3089        self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
3090
3091        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
3092            tracing::error!("Failed to set virtual buffer content: {}", e);
3093            return;
3094        }
3095
3096        // Show the buffer in the target split. set_pane_buffer
3097        // covers the tree + SVS updates the old code did by hand.
3098        let leaf_id = LeafId(split_id);
3099        self.windows
3100            .get_mut(&self.active_window)
3101            .and_then(|w| w.split_manager_mut())
3102            .expect("active window must have a populated split layout")
3103            .set_active_split(leaf_id);
3104        self.active_window_mut().set_pane_buffer(leaf_id, buffer_id);
3105
3106        // Fall-through to the cursor/open_buffers housekeeping
3107        // that used to follow the manual switch_buffer. We keep
3108        // the `if let Some(view_state)` block below — set_pane_buffer
3109        // already called switch_buffer, but the downstream code
3110        // also nudges open_buffers and focus_history.
3111        if let Some(view_state) = self
3112            .windows
3113            .get_mut(&self.active_window)
3114            .and_then(|w| w.split_view_states_mut())
3115            .expect("active window must have a populated split layout")
3116            .get_mut(&leaf_id)
3117        {
3118            view_state.switch_buffer(buffer_id);
3119            view_state.add_buffer(buffer_id);
3120            view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
3121
3122            // Apply line_wrap setting if provided
3123            if let Some(wrap) = line_wrap {
3124                view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
3125            }
3126        }
3127
3128        tracing::info!(
3129            "Displayed virtual buffer {:?} in split {:?}",
3130            buffer_id,
3131            split_id
3132        );
3133
3134        // Send response with buffer ID and split ID via callback resolution
3135        if let Some(req_id) = request_id {
3136            let result = fresh_core::api::VirtualBufferResult {
3137                buffer_id: buffer_id.0 as u64,
3138                split_id: Some(split_id.0 as u64),
3139            };
3140            self.plugin_manager.read().unwrap().resolve_callback(
3141                fresh_core::api::JsCallbackId::from(req_id),
3142                serde_json::to_string(&result).unwrap_or_default(),
3143            );
3144        }
3145    }
3146
3147    fn handle_show_action_popup(
3148        &mut self,
3149        popup_id: String,
3150        title: String,
3151        message: String,
3152        actions: Vec<fresh_core::api::ActionPopupAction>,
3153    ) {
3154        tracing::info!(
3155            "Action popup requested: id={}, title={}, actions={}",
3156            popup_id,
3157            title,
3158            actions.len()
3159        );
3160
3161        // Build popup list items from actions
3162        let items: Vec<crate::model::event::PopupListItemData> = actions
3163            .iter()
3164            .map(|action| crate::model::event::PopupListItemData {
3165                text: action.label.clone(),
3166                detail: None,
3167                icon: None,
3168                data: Some(action.id.clone()),
3169            })
3170            .collect();
3171
3172        // The popup_id lives on the popup itself via its
3173        // `PopupResolver::PluginAction` — no side-channel stack.
3174        // Drop the incoming `actions` vec; its ids are already
3175        // encoded as each list item's `data` field below.
3176        drop(actions);
3177
3178        // Create popup with message + action list
3179        let popup_data = crate::model::event::PopupData {
3180            kind: crate::model::event::PopupKindHint::List,
3181            title: Some(title),
3182            description: Some(message),
3183            transient: false,
3184            content: crate::model::event::PopupContentData::List { items, selected: 0 },
3185            position: crate::model::event::PopupPositionData::BottomRight,
3186            width: 60,
3187            max_height: 15,
3188            bordered: true,
3189        };
3190
3191        // Action popups are buffer-independent notifications; route
3192        // them to the editor-level popup stack so they remain visible
3193        // (and dismissible) regardless of which buffer is focused —
3194        // including virtual buffers like the Dashboard that own the
3195        // whole split.
3196        //
3197        // The resolver carries the popup_id so confirm/cancel fires
3198        // `action_popup_result` for exactly THIS popup, even when
3199        // multiple plugin popups are stacked concurrently.
3200        let (popup_bg, popup_border_fg) = {
3201            let theme = self.theme();
3202            (theme.popup_bg, theme.popup_border_fg)
3203        };
3204        let mut popup_obj =
3205            crate::state::convert_popup_data_to_popup(&popup_data, popup_bg, popup_border_fg);
3206        popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
3207            popup_id: popup_id.clone(),
3208        };
3209
3210        // Dismiss any built-in LSP-status popup that the editor put
3211        // on `active_state().popups` in response to the same click —
3212        // the plugin's popup is the contextual answer and stacking
3213        // ours underneath leaves two popups for one user gesture
3214        // (#1941 issue 1). Done here (rather than at the
3215        // `show_lsp_status_popup` call site) because plugin handlers
3216        // run *asynchronously*: by the time the `ShowActionPopup`
3217        // command reaches us, the LSP-Servers popup has already
3218        // landed. Re-run on every plugin push (not just the first
3219        // dedup'd one) because rapid repeated clicks can re-add the
3220        // LSP-Servers popup between consecutive plugin commands.
3221        while self
3222            .active_state()
3223            .popups
3224            .top()
3225            .is_some_and(|p| matches!(p.resolver, crate::view::popup::PopupResolver::LspStatus))
3226        {
3227            self.active_state_mut().popups.hide();
3228        }
3229
3230        // Dedup by `popup_id`: if a previous `showActionPopup` with
3231        // the same id is still on the stack (common: repeated
3232        // indicator clicks fire `lsp_status_clicked` over and over,
3233        // each one re-pushing "rust-lsp-help"), replace it in place
3234        // instead of stacking another copy. Without this, dismissing
3235        // one reveals the same popup underneath — #1941 issue 4.
3236        let existing_idx = self.global_popups.all().iter().position(|p| {
3237            matches!(
3238                &p.resolver,
3239                crate::view::popup::PopupResolver::PluginAction { popup_id: id } if id == &popup_id,
3240            )
3241        });
3242        if let Some(idx) = existing_idx {
3243            if let Some(slot) = self.global_popups.get_mut(idx) {
3244                *slot = popup_obj;
3245            }
3246        } else {
3247            self.global_popups.show(popup_obj);
3248        }
3249        tracing::info!(
3250            "Action popup shown: id={}, stack_depth={}",
3251            popup_id,
3252            self.global_popups.all().len()
3253        );
3254    }
3255
3256    /// Install (or replace, or clear) a plugin's contributions for the
3257    /// LSP-Servers popup. Passing an empty `items` removes any
3258    /// previous contribution from this `plugin_id` for this
3259    /// `language`. Mirrors the editor-side half of
3260    /// `PluginCommand::SetLspMenuContributions`.
3261    ///
3262    /// If the LSP-Servers popup is currently open for this language,
3263    /// refresh it in place so the new rows show up immediately
3264    /// rather than only on the next click.
3265    fn handle_set_lsp_menu_contributions(
3266        &mut self,
3267        plugin_id: String,
3268        language: String,
3269        items: Vec<fresh_core::api::LspMenuItem>,
3270    ) {
3271        let key = (language.clone(), plugin_id.clone());
3272        if items.is_empty() {
3273            self.active_window_mut().lsp_menu_contributions.remove(&key);
3274        } else {
3275            self.active_window_mut()
3276                .lsp_menu_contributions
3277                .insert(key, items);
3278        }
3279        // If the popup is on screen right now, re-render it so the
3280        // change is immediately visible — the alternative is "next
3281        // click sees it" which feels unresponsive when the plugin
3282        // is reacting to an event the user just triggered.
3283        self.refresh_lsp_status_popup_if_open();
3284    }
3285
3286    #[allow(clippy::too_many_arguments)]
3287    fn handle_create_window_with_terminal(
3288        &mut self,
3289        root: std::path::PathBuf,
3290        label: String,
3291        cwd: Option<String>,
3292        command: Option<Vec<String>>,
3293        title: Option<String>,
3294        resume: Option<Vec<String>>,
3295        request_id: u64,
3296    ) {
3297        let callback_id = JsCallbackId::from(request_id);
3298        if !root.is_absolute() {
3299            let msg = format!(
3300                "createWindowWithTerminal: root must be absolute, got {:?}",
3301                root
3302            );
3303            tracing::warn!("{}", msg);
3304            self.plugin_manager
3305                .read()
3306                .unwrap()
3307                .reject_callback(callback_id, msg);
3308            return;
3309        }
3310        let cwd_buf = cwd.map(std::path::PathBuf::from);
3311        // The Orchestrator's "New Session (Local)" flow births its own local
3312        // backend rather than inheriting the active window's (which may be a
3313        // container/SSH/k8s session). Remote sessions take the
3314        // `attachRemoteAgent` → `create_remote_session_window` path, which
3315        // passes its connected authority. `resume` is the agent-resume argv
3316        // carried through to the new session's terminal. The new local
3317        // session gets its own per-session trust scoped to its root.
3318        let new_authority = self.local_session_authority(&root);
3319        match self.create_window_with_terminal(
3320            root,
3321            label,
3322            cwd_buf,
3323            command,
3324            title,
3325            new_authority,
3326            resume,
3327        ) {
3328            Ok((window_id, terminal_id, buffer_id)) => {
3329                let api_result = fresh_core::api::SessionWithTerminalResult {
3330                    window_id: window_id.0,
3331                    terminal_id: terminal_id.0 as u64,
3332                    buffer_id: buffer_id.0 as u64,
3333                };
3334                self.plugin_manager.read().unwrap().resolve_callback(
3335                    callback_id,
3336                    serde_json::to_string(&api_result).unwrap_or_default(),
3337                );
3338            }
3339            Err(e) => {
3340                tracing::error!("createWindowWithTerminal failed: {e}");
3341                self.plugin_manager
3342                    .read()
3343                    .unwrap()
3344                    .reject_callback(callback_id, format!("createWindowWithTerminal: {e}"));
3345            }
3346        }
3347    }
3348
3349    #[allow(clippy::too_many_arguments)]
3350    fn handle_create_terminal(
3351        &mut self,
3352        cwd: Option<String>,
3353        direction: Option<String>,
3354        ratio: Option<f32>,
3355        focus: Option<bool>,
3356        persistent: bool,
3357        target_session_id: Option<fresh_core::WindowId>,
3358        command: Option<Vec<String>>,
3359        title: Option<String>,
3360        request_id: u64,
3361    ) {
3362        // Resolve target window. Explicit `windowId` wins when the
3363        // window exists; otherwise we operate on the active window.
3364        // Both cases route through `Window::create_plugin_terminal`
3365        // so spawning into an inactive session reuses the same code
3366        // path — no separate migration helper, no half-state leaks
3367        // between windows.
3368        let target_id = target_session_id
3369            .filter(|id| self.windows.contains_key(id))
3370            .unwrap_or(self.active_window);
3371        let is_active_target = target_id == self.active_window;
3372
3373        let cwd_buf = cwd.map(std::path::PathBuf::from);
3374        let split_direction = direction.as_deref().map(|d| match d {
3375            "horizontal" => crate::model::event::SplitDirection::Horizontal,
3376            _ => crate::model::event::SplitDirection::Vertical,
3377        });
3378
3379        // Capture the editor-active buffer before the spawn so we
3380        // can detect whether `Window::create_plugin_terminal`'s
3381        // per-window mutations also flipped the editor-active buffer
3382        // (only possible when `is_active_target`). If it did, the
3383        // `buffer_activated` plugin hook needs to fire here at the
3384        // Editor level — the Window method only mutates per-window
3385        // state.
3386        let prev_active = if is_active_target {
3387            Some(self.active_window().active_buffer())
3388        } else {
3389            None
3390        };
3391
3392        let result = {
3393            let target = self
3394                .windows
3395                .get_mut(&target_id)
3396                .expect("target window present (existence checked above)");
3397            target.create_plugin_terminal(
3398                cwd_buf,
3399                split_direction,
3400                ratio,
3401                focus.unwrap_or(true),
3402                persistent,
3403                command,
3404                title.filter(|t| !t.is_empty()),
3405            )
3406        };
3407        match result {
3408            Ok((terminal_id, buffer_id, created_split_id)) => {
3409                if is_active_target {
3410                    let new_active = self.active_window().active_buffer();
3411                    if prev_active != Some(new_active) {
3412                        #[cfg(feature = "plugins")]
3413                        self.update_plugin_state_snapshot();
3414                        #[cfg(feature = "plugins")]
3415                        self.plugin_manager.read().unwrap().run_hook(
3416                            "buffer_activated",
3417                            crate::services::plugins::hooks::HookArgs::BufferActivated {
3418                                buffer_id: new_active,
3419                            },
3420                        );
3421                    }
3422                }
3423                let api_result = fresh_core::api::TerminalResult {
3424                    buffer_id: buffer_id.0 as u64,
3425                    terminal_id: terminal_id.0 as u64,
3426                    split_id: created_split_id.map(|s| s.0 .0 as u64),
3427                };
3428                self.plugin_manager.read().unwrap().resolve_callback(
3429                    fresh_core::api::JsCallbackId::from(request_id),
3430                    serde_json::to_string(&api_result).unwrap_or_default(),
3431                );
3432                tracing::info!(
3433                    "Plugin created terminal {:?} with buffer {:?} in window {:?}",
3434                    terminal_id,
3435                    buffer_id,
3436                    target_id
3437                );
3438            }
3439            Err(e) => {
3440                tracing::error!("Failed to create terminal for plugin: {e}");
3441                self.plugin_manager.read().unwrap().reject_callback(
3442                    fresh_core::api::JsCallbackId::from(request_id),
3443                    format!("Failed to create terminal: {e}"),
3444                );
3445            }
3446        }
3447    }
3448
3449    // ==================== Extracted handlers for previously inline match arms ====================
3450
3451    fn handle_get_split_by_label(&mut self, label: String, request_id: u64) {
3452        let split_id = self
3453            .windows
3454            .get(&self.active_window)
3455            .and_then(|w| w.buffers.splits())
3456            .map(|(mgr, _)| mgr)
3457            .expect("active window must have a populated split layout")
3458            .find_split_by_label(&label);
3459        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
3460        let json =
3461            serde_json::to_string(&split_id.map(|s| s.0 .0)).unwrap_or_else(|_| "null".to_string());
3462        self.plugin_manager
3463            .read()
3464            .unwrap()
3465            .resolve_callback(callback_id, json);
3466    }
3467
3468    fn handle_set_buffer_show_cursors(&mut self, buffer_id: BufferId, show: bool) {
3469        if let Some(state) = self
3470            .windows
3471            .get_mut(&self.active_window)
3472            .map(|w| &mut w.buffers)
3473            .expect("active window present")
3474            .get_mut(&buffer_id)
3475        {
3476            state.show_cursors = show;
3477            // The plugin now owns this buffer's cursor visibility; stop
3478            // the widget runtime from overriding it on every repaint.
3479            state.cursor_visibility_locked = true;
3480        } else {
3481            tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
3482        }
3483    }
3484
3485    fn handle_override_theme_colors(
3486        &mut self,
3487        overrides: std::collections::HashMap<String, [u8; 3]>,
3488    ) {
3489        let pairs = overrides
3490            .into_iter()
3491            .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
3492        let applied = self.theme.write().unwrap().override_colors(pairs);
3493        if applied > 0 {
3494            // Diagnostics / semantic overlays bake RGB at creation time — rebuild
3495            // them so the override is visible everywhere on the next frame.
3496            self.reapply_all_overlays();
3497        }
3498    }
3499
3500    fn handle_await_next_key(&mut self, callback_id: fresh_core::api::JsCallbackId) {
3501        // If keys arrived during a key-capture window while no callback was
3502        // pending, drain the front-most buffered key and resolve immediately.
3503        // Otherwise enqueue the callback for the next live keypress.
3504        if let Some(payload) = self
3505            .active_window_mut()
3506            .pending_key_capture_buffer
3507            .pop_front()
3508        {
3509            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
3510            self.plugin_manager
3511                .read()
3512                .unwrap()
3513                .resolve_callback(callback_id, json);
3514        } else {
3515            self.active_window_mut()
3516                .pending_next_key_callbacks
3517                .push_back(callback_id);
3518        }
3519    }
3520
3521    fn handle_spawn_process(
3522        &mut self,
3523        command: String,
3524        args: Vec<String>,
3525        cwd: Option<String>,
3526        stdout_to: Option<std::path::PathBuf>,
3527        callback_id: fresh_core::api::JsCallbackId,
3528    ) {
3529        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3530            let effective_cwd = cwd.or_else(|| {
3531                std::env::current_dir()
3532                    .map(|p| p.to_string_lossy().to_string())
3533                    .ok()
3534            });
3535            let sender = bridge.sender();
3536            let spawner = self.authority().process_spawner.clone();
3537
3538            // Kill plumbing: register a oneshot keyed by process_id, same
3539            // pattern as handle_spawn_host_process. JS calls
3540            // `_killHostProcess(id)` → `handle_kill_host_process` fires
3541            // the tx; the spawner's `spawn_cancellable` races against rx.
3542            let process_id = callback_id.as_u64();
3543            let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
3544            self.host_process_handles.insert(process_id, kill_tx);
3545
3546            runtime.spawn(async move {
3547                #[allow(clippy::let_underscore_must_use)]
3548                let outcome = spawner
3549                    .spawn_cancellable(command, args, effective_cwd, stdout_to, kill_rx)
3550                    .await;
3551                match outcome {
3552                    Ok(result) => {
3553                        #[allow(clippy::let_underscore_must_use)]
3554                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
3555                            process_id,
3556                            stdout: result.stdout,
3557                            stderr: result.stderr,
3558                            exit_code: result.exit_code,
3559                        });
3560                    }
3561                    Err(e) => {
3562                        #[allow(clippy::let_underscore_must_use)]
3563                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
3564                            process_id,
3565                            stdout: String::new(),
3566                            stderr: e.to_string(),
3567                            exit_code: -1,
3568                        });
3569                    }
3570                }
3571            });
3572        } else {
3573            self.plugin_manager
3574                .read()
3575                .unwrap()
3576                .reject_callback(callback_id, "Async runtime not available".to_string());
3577        }
3578    }
3579
3580    fn handle_kill_host_process(&mut self, process_id: u64) {
3581        // Removing from the map gives us the oneshot sender. Firing it signals
3582        // the spawn task to start_kill() the child and reap. Unknown IDs are
3583        // intentionally silent — the process may have already exited.
3584        if let Some(tx) = self.host_process_handles.remove(&process_id) {
3585            #[allow(clippy::let_underscore_must_use)]
3586            let _ = tx.send(());
3587            tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
3588        } else {
3589            tracing::debug!(
3590                "KillHostProcess: unknown process_id={} (already exited?)",
3591                process_id
3592            );
3593        }
3594    }
3595
3596    fn handle_set_authority(&mut self, payload: serde_json::Value) {
3597        // Payload is opaque at the fresh-core layer; the concrete schema lives
3598        // in services::authority::AuthorityPayload so core stays ignorant of backend kinds.
3599        match serde_json::from_value::<crate::services::authority::AuthorityPayload>(payload) {
3600            Ok(parsed) => {
3601                // The new authority shares the editor's live trust + env
3602                // handles, so its spawners are gated and env'd identically.
3603                let trust = std::sync::Arc::clone(&self.authority().workspace_trust);
3604                let env = std::sync::Arc::clone(&self.authority().env_provider);
3605                // Record the spec on the active session *before* the restart
3606                // so it persists (save-on-restart) and the rebuilt editor
3607                // restores this session under the same backend instead of
3608                // degrading it to local. The payload is cloned because
3609                // `from_plugin_payload` consumes it.
3610                let spec = crate::services::authority::SessionAuthoritySpec::Plugin(parsed.clone());
3611                match crate::services::authority::Authority::from_plugin_payload(parsed, trust, env)
3612                {
3613                    Ok(auth) => {
3614                        tracing::info!("Plugin installed new authority");
3615                        self.active_window_mut().authority_spec = spec;
3616                        self.install_authority(auth);
3617                    }
3618                    Err(e) => {
3619                        tracing::warn!("setAuthority: invalid payload: {}", e);
3620                        self.set_status_message(format!("setAuthority rejected: {}", e));
3621                    }
3622                }
3623            }
3624            Err(e) => {
3625                tracing::warn!("setAuthority: failed to parse payload: {}", e);
3626                self.set_status_message(format!("setAuthority rejected: {}", e));
3627            }
3628        }
3629    }
3630
3631    /// If the just-activated window is a **dormant remote** session — restored
3632    /// from disk so its backend spec is remote but its live authority is still
3633    /// the local placeholder (no live keepalive) — start reconnecting it. SSH /
3634    /// Kubernetes reconnect from core via [`Self::start_remote_connect`]; a
3635    /// container (`Plugin`) session needs its owning plugin to re-attach (only
3636    /// the devcontainer plugin can run `devcontainer up`), left to a follow-up.
3637    /// Idempotent: a reconnect already in flight for this window is a no-op.
3638    pub(crate) fn reconnect_dormant_session_if_needed(&mut self, window_id: fresh_core::WindowId) {
3639        // A live session already holds its connection (keepalive); a local
3640        // session has nothing to reconnect.
3641        if self.session_keepalives.contains_key(&window_id) {
3642            return;
3643        }
3644        let Some(spec) = self
3645            .windows
3646            .get(&window_id)
3647            .map(|w| w.authority_spec.clone())
3648        else {
3649            return;
3650        };
3651        match spec {
3652            crate::services::authority::SessionAuthoritySpec::Local => {}
3653            crate::services::authority::SessionAuthoritySpec::RemoteAgent(agent_spec) => {
3654                // Synthetic, window-derived request id (well clear of the
3655                // low-numbered JS callback ids) so the in-flight/cancel
3656                // tracking works and a repeated switch doesn't double-connect.
3657                let request_id = u64::MAX - window_id.0 as u64;
3658                if self.remote_attach_inflight.contains(&request_id) {
3659                    return;
3660                }
3661                self.start_remote_connect(agent_spec, Some(window_id), request_id);
3662            }
3663            crate::services::authority::SessionAuthoritySpec::Plugin(_) => {
3664                // Container: only the owning plugin can rebuild the backend
3665                // (`devcontainer up`). TODO(per-session): fire a
3666                // `session_reattach_requested` hook so it can.
3667                tracing::debug!(
3668                    "dormant container session {window_id}: reattach is plugin-driven (TODO)"
3669                );
3670            }
3671        }
3672    }
3673
3674    fn handle_attach_remote_agent(&mut self, payload: serde_json::Value, request_id: u64) {
3675        // Opaque at the fresh-core boundary; the concrete schema lives in
3676        // services::authority so core stays backend-agnostic.
3677        let spec =
3678            match serde_json::from_value::<crate::services::authority::RemoteAgentSpec>(payload) {
3679                Ok(spec) => spec,
3680                Err(e) => {
3681                    tracing::warn!("attachRemoteAgent: invalid payload: {}", e);
3682                    self.reject_remote_attach(request_id, format!("invalid attach spec: {e}"));
3683                    return;
3684                }
3685            };
3686        // A plugin attach: spawn a born-attached window or restart (per
3687        // `spec.window`), not a reconnect of an existing one.
3688        self.start_remote_connect(spec, None, request_id);
3689    }
3690
3691    /// Spawn the async remote connect (carrier + agent bootstrap) for `spec`
3692    /// and report the result back via the bridge. Shared by the plugin
3693    /// `attachRemoteAgent` op and the reconnect-on-activate path:
3694    /// `reconnect_window = Some(id)` re-points *that dormant window's*
3695    /// authority on success (no new window / no restart); `None` follows
3696    /// `spec.window` (born-attached window vs. global restart).
3697    pub(crate) fn start_remote_connect(
3698        &mut self,
3699        spec: crate::services::authority::RemoteAgentSpec,
3700        reconnect_window: Option<fresh_core::WindowId>,
3701        request_id: u64,
3702    ) {
3703        // Take owned handles up front so the immutable borrows of `self`
3704        // end before the mutable `set_status_message` / spawn below.
3705        let runtime = self.tokio_runtime.clone();
3706        let sender = self.async_bridge.as_ref().map(|b| b.sender());
3707        let (Some(runtime), Some(sender)) = (runtime, sender) else {
3708            self.reject_remote_attach(request_id, "async runtime not available".to_string());
3709            return;
3710        };
3711
3712        // Track this connect as in-flight so a plugin can cancel it (the
3713        // New-Session dialog's Cancel) before it resolves. The cancel sender is
3714        // handed to the connect via its `select!`; signalling it tears down the
3715        // in-flight carrier child.
3716        self.remote_attach_inflight.insert(request_id);
3717        let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
3718        self.remote_attach_cancels.insert(request_id, cancel_tx);
3719
3720        // Window-mode opts captured before `spec` is consumed — when `window`
3721        // is set the main loop spawns a born-attached new window instead of
3722        // restarting the whole editor.
3723        let window_mode = spec.window;
3724        let window_label = spec.label.clone();
3725        let window_command = spec.command.clone();
3726        // A remote session gets its **own** fresh trust + env handles — never
3727        // a clone of the launching session's `Arc`s — so a born-attached
3728        // remote window can't share (and leak) trust/env with the window it
3729        // was launched from. The trust *level* is copied by value from the
3730        // launching context (independent handle thereafter); env starts
3731        // inactive (the remote's env rides the spawner's captured probe).
3732        let trust = std::sync::Arc::new(crate::services::workspace_trust::WorkspaceTrust::new(
3733            None,
3734            self.authority().workspace_trust.level(),
3735        ));
3736        let env = std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive());
3737
3738        // The connect (spawn the carrier, bootstrap the agent, await `ready`)
3739        // is async and can take seconds, so run it on the runtime and report
3740        // back via the bridge instead of blocking the event loop. On success
3741        // the main loop installs the authority + keepalive (restart or new
3742        // window); on failure it surfaces the error. Both transports converge
3743        // on the same `RemoteAttachReady`; only the connect future differs.
3744        use crate::services::authority::RemoteTransportSpec;
3745        let base_env = spec.base_env.clone();
3746        // The reconnect spec persisted on the session (so a restart can bring
3747        // this remote backend back). Cloned before `spec` is consumed below.
3748        let session_spec =
3749            crate::services::authority::SessionAuthoritySpec::RemoteAgent(spec.clone());
3750        let mode_for = |label: &str| {
3751            if let Some(window_id) = reconnect_window {
3752                crate::services::async_bridge::RemoteAttachMode::Reconnect { window_id }
3753            } else if window_mode {
3754                crate::services::async_bridge::RemoteAttachMode::Window {
3755                    label: window_label.clone().unwrap_or_else(|| label.to_string()),
3756                    command: window_command.clone(),
3757                }
3758            } else {
3759                crate::services::async_bridge::RemoteAttachMode::Restart
3760            }
3761        };
3762
3763        match spec.transport {
3764            RemoteTransportSpec::KubectlExec { .. } => {
3765                let (target, base_env) = spec.into_kube_target();
3766                let label = target.display();
3767                // Pod-side workspace to re-root at (e.g. `/workspace`).
3768                let workspace = target.workspace.clone().map(std::path::PathBuf::from);
3769                let mode = mode_for(&label);
3770                self.set_status_message(format!("Connecting to {label}…"));
3771                runtime.spawn(async move {
3772                    let outcome = crate::services::authority::connect_kube_authority(
3773                        target,
3774                        base_env,
3775                        trust,
3776                        env,
3777                        Some(cancel_rx),
3778                    )
3779                    .await;
3780                    let msg = match outcome {
3781                        Ok((authority, keepalive)) => AsyncMessage::RemoteAttachReady(
3782                            crate::services::async_bridge::RemoteAttachReady {
3783                                authority,
3784                                keepalive: Box::new(keepalive),
3785                                working_dir: workspace,
3786                                mode,
3787                                spec: session_spec,
3788                                request_id,
3789                            },
3790                        ),
3791                        Err(e) => AsyncMessage::RemoteAttachFailed {
3792                            error: e.to_string(),
3793                            request_id,
3794                        },
3795                    };
3796                    #[allow(clippy::let_underscore_must_use)]
3797                    let _ = sender.send(msg);
3798                });
3799            }
3800            RemoteTransportSpec::Ssh {
3801                user,
3802                host,
3803                port,
3804                identity_file,
3805                remote_path,
3806                extra_args,
3807            } => {
3808                let _ = base_env; // SSH probes its own env on the remote host.
3809                let params = crate::services::remote::ConnectionParams {
3810                    user: user.clone().filter(|u| !u.is_empty()),
3811                    host: host.clone(),
3812                    port,
3813                    identity_file: identity_file.map(std::path::PathBuf::from),
3814                    extra_args,
3815                };
3816                // Label: `user@host` when a user was given, else bare `host`.
3817                let target = params.ssh_target();
3818                let label = match port {
3819                    Some(p) => format!("ssh:{target}:{p}"),
3820                    None => format!("ssh:{target}"),
3821                };
3822                let workspace = remote_path.clone().map(std::path::PathBuf::from);
3823                let mode = mode_for(&label);
3824                self.set_status_message(format!("Connecting to {label}…"));
3825                runtime.spawn(async move {
3826                    let outcome = crate::services::authority::connect_ssh_authority(
3827                        params,
3828                        remote_path,
3829                        trust,
3830                        env,
3831                        Some(cancel_rx),
3832                    )
3833                    .await;
3834                    let msg = match outcome {
3835                        Ok((authority, keepalive)) => AsyncMessage::RemoteAttachReady(
3836                            crate::services::async_bridge::RemoteAttachReady {
3837                                authority,
3838                                keepalive: Box::new(keepalive),
3839                                working_dir: workspace,
3840                                mode,
3841                                spec: session_spec,
3842                                request_id,
3843                            },
3844                        ),
3845                        Err(e) => AsyncMessage::RemoteAttachFailed {
3846                            error: e.to_string(),
3847                            request_id,
3848                        },
3849                    };
3850                    #[allow(clippy::let_underscore_must_use)]
3851                    let _ = sender.send(msg);
3852                });
3853            }
3854        }
3855    }
3856
3857    fn handle_set_remote_indicator_state(&mut self, state: serde_json::Value) {
3858        // Opaque JSON at the fresh-core boundary; the concrete schema
3859        // (RemoteIndicatorOverride) lives in the view crate.
3860        match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(state)
3861        {
3862            Ok(over) => {
3863                self.remote_indicator_override = Some(over);
3864            }
3865            Err(e) => {
3866                tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
3867                self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
3868            }
3869        }
3870    }
3871
3872    fn handle_spawn_process_wait(
3873        &mut self,
3874        process_id: u64,
3875        callback_id: fresh_core::api::JsCallbackId,
3876    ) {
3877        tracing::warn!(
3878            "SpawnProcessWait not fully implemented - process_id={}",
3879            process_id
3880        );
3881        self.plugin_manager.read().unwrap().reject_callback(
3882            callback_id,
3883            format!(
3884                "SpawnProcessWait not yet fully implemented for process_id={}",
3885                process_id
3886            ),
3887        );
3888    }
3889
3890    fn handle_delay(&mut self, callback_id: fresh_core::api::JsCallbackId, duration_ms: u64) {
3891        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3892            let sender = bridge.sender();
3893            let callback_id_u64 = callback_id.as_u64();
3894            runtime.spawn(async move {
3895                tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
3896                #[allow(clippy::let_underscore_must_use)]
3897                let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
3898                    fresh_core::api::PluginAsyncMessage::DelayComplete {
3899                        callback_id: callback_id_u64,
3900                    },
3901                ));
3902            });
3903        } else {
3904            std::thread::sleep(std::time::Duration::from_millis(duration_ms));
3905            self.plugin_manager
3906                .read()
3907                .unwrap()
3908                .resolve_callback(callback_id, "null".to_string());
3909        }
3910    }
3911
3912    fn handle_http_fetch(
3913        &mut self,
3914        url: String,
3915        target_path: std::path::PathBuf,
3916        callback_id: fresh_core::api::JsCallbackId,
3917    ) {
3918        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3919            let sender = bridge.sender();
3920            let process_id = callback_id.as_u64();
3921
3922            runtime.spawn(async move {
3923                let fetch = tokio::task::spawn_blocking(move || {
3924                    crate::services::http::download_to_file(&url, &target_path)
3925                })
3926                .await;
3927
3928                let (stdout, stderr, exit_code) = match fetch {
3929                    Ok(Ok(status)) => {
3930                        if (200..300).contains(&status) {
3931                            (String::new(), String::new(), 0)
3932                        } else {
3933                            (String::new(), format!("HTTP {}", status), i32::from(status))
3934                        }
3935                    }
3936                    Ok(Err(e)) => (String::new(), e, -1),
3937                    Err(e) => (String::new(), format!("fetch task failed: {}", e), -1),
3938                };
3939
3940                #[allow(clippy::let_underscore_must_use)]
3941                let _ = sender.send(AsyncMessage::PluginProcessOutput {
3942                    process_id,
3943                    stdout,
3944                    stderr,
3945                    exit_code,
3946                });
3947            });
3948        } else {
3949            self.plugin_manager
3950                .read()
3951                .unwrap()
3952                .reject_callback(callback_id, "Async runtime not available".to_string());
3953        }
3954    }
3955
3956    fn handle_kill_background_process(&mut self, process_id: u64) {
3957        if let Some(handle) = self.background_process_handles.remove(&process_id) {
3958            handle.abort();
3959            tracing::debug!("Killed background process {}", process_id);
3960        }
3961    }
3962
3963    fn handle_create_virtual_buffer(&mut self, name: String, mode: String, read_only: bool) {
3964        let buffer_id =
3965            self.active_window_mut()
3966                .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3967        tracing::info!(
3968            "Created virtual buffer '{}' with mode '{}' (id={:?})",
3969            name,
3970            mode,
3971            buffer_id
3972        );
3973        // TODO: Return buffer_id to plugin via callback or hook
3974    }
3975
3976    fn handle_set_virtual_buffer_content(
3977        &mut self,
3978        buffer_id: BufferId,
3979        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3980    ) {
3981        match self.set_virtual_buffer_content(buffer_id, entries) {
3982            Ok(()) => {
3983                tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
3984            }
3985            Err(e) => {
3986                tracing::error!("Failed to set virtual buffer content: {}", e);
3987            }
3988        }
3989    }
3990
3991    fn handle_mount_widget_panel(
3992        &mut self,
3993        panel_key: crate::widgets::PanelKey,
3994        buffer_id: BufferId,
3995        spec: fresh_core::api::WidgetSpec,
3996    ) {
3997        // Mount = clean slate. Instance state and focus key reset
3998        // so a plugin that re-mounts (e.g. reopening a panel with
3999        // a fresh prefill) sees its spec values take effect. To
4000        // *preserve* state across renders, the plugin uses Update.
4001        let prev = std::collections::HashMap::new();
4002        let prev_focus = String::new();
4003        let panel_width = self.widget_panel_width(buffer_id);
4004        let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
4005        let focus_cursor = out.focus_cursor;
4006        self.widget_registry.mount(
4007            panel_key.clone(),
4008            buffer_id,
4009            spec,
4010            out.hits,
4011            out.instance_states,
4012            out.focus_key,
4013            out.tabbable,
4014        );
4015        let entries = out.entries;
4016        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
4017            tracing::error!(
4018                "Failed to render mounted widget panel {} into {:?}: {}",
4019                panel_key,
4020                buffer_id,
4021                e
4022            );
4023        } else {
4024            tracing::debug!(
4025                "Mounted widget panel {} into buffer {:?}",
4026                panel_key,
4027                buffer_id
4028            );
4029        }
4030        self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
4031    }
4032
4033    fn handle_update_widget_panel(
4034        &mut self,
4035        panel_key: &crate::widgets::PanelKey,
4036        spec: fresh_core::api::WidgetSpec,
4037    ) {
4038        let prev = match self.widget_registry.instance_states(panel_key) {
4039            Some(s) => s.clone(),
4040            None => {
4041                tracing::debug!(
4042                    "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
4043                    panel_key
4044                );
4045                return;
4046            }
4047        };
4048        let prev_focus = self
4049            .widget_registry
4050            .focus_key(panel_key)
4051            .map(|s| s.to_string())
4052            .unwrap_or_default();
4053        let buffer_id_for_width = self
4054            .widget_registry
4055            .buffer_and_spec(panel_key)
4056            .map(|(b, _)| b)
4057            .unwrap_or(BufferId(0));
4058        let panel_width = self.widget_panel_width(buffer_id_for_width);
4059        let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
4060        let focus_cursor = out.focus_cursor;
4061        let entries = out.entries;
4062        match self.widget_registry.update(
4063            panel_key,
4064            spec,
4065            out.hits,
4066            out.instance_states,
4067            out.focus_key,
4068            out.tabbable,
4069        ) {
4070            Ok(buffer_id) => {
4071                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
4072                    tracing::error!("Failed to render updated widget panel {}: {}", panel_key, e);
4073                }
4074                self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
4075            }
4076            Err(()) => {
4077                tracing::debug!(
4078                    "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
4079                    panel_key
4080                );
4081            }
4082        }
4083    }
4084
4085    /// Apply a `WidgetMutation` in place, then re-render the panel.
4086    /// This is the IPC fast path: the plugin doesn't re-transmit
4087    /// the full spec; it sends one targeted change. The host
4088    /// mutates the registry's spec / instance state and re-renders
4089    /// against the just-mutated state.
4090    fn handle_widget_mutate(
4091        &mut self,
4092        panel_key: &crate::widgets::PanelKey,
4093        mutation: fresh_core::api::WidgetMutation,
4094    ) {
4095        use fresh_core::api::WidgetMutation;
4096
4097        // Look up the panel; bail if unknown.
4098        if self.widget_registry.get(panel_key).is_none() {
4099            tracing::debug!(
4100                "WidgetMutate for unknown panel {} ignored (not mounted)",
4101                panel_key
4102            );
4103            return;
4104        }
4105
4106        match mutation {
4107            WidgetMutation::SetValue {
4108                widget_key,
4109                value,
4110                cursor_byte,
4111            } => {
4112                // Value+cursor live in instance state for the unified
4113                // Text widget. Preserve `scroll` and `multiline` from
4114                // the previous editor across the mutation so
4115                // multi-line viewport offsets don't snap on a
4116                // plugin-driven update; the renderer re-clamps next
4117                // render anyway.
4118                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4119                    // Preserve `scroll` + `multiline` so plugin-
4120                    // driven SetValue doesn't snap the viewport,
4121                    // and preserve `completions` /
4122                    // `completion_selected_index` so the popup
4123                    // (if open) doesn't disappear on a value
4124                    // mutation that happens to land while the
4125                    // user is mid-keystroke.
4126                    let (scroll, multiline, completions, sel_idx, scroll_off, navigated) =
4127                        match panel.instance_states.get(&widget_key) {
4128                            Some(crate::widgets::WidgetInstanceState::Text {
4129                                editor,
4130                                scroll,
4131                                completions,
4132                                completion_selected_index,
4133                                completion_scroll_offset,
4134                                completion_navigated,
4135                            }) => (
4136                                *scroll,
4137                                editor.multiline,
4138                                completions.clone(),
4139                                *completion_selected_index,
4140                                *completion_scroll_offset,
4141                                *completion_navigated,
4142                            ),
4143                            _ => (0u32, true, Vec::new(), 0usize, 0u32, false),
4144                        };
4145                    let mut editor = if multiline {
4146                        crate::primitives::text_edit::TextEdit::with_text(&value)
4147                    } else {
4148                        crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
4149                    };
4150                    let target = match cursor_byte {
4151                        Some(c) if c >= 0 => (c as usize).min(value.len()),
4152                        _ => value.len(),
4153                    };
4154                    editor.set_cursor_from_flat(target);
4155                    panel.instance_states.insert(
4156                        widget_key,
4157                        crate::widgets::WidgetInstanceState::Text {
4158                            editor,
4159                            scroll,
4160                            completions,
4161                            completion_selected_index: sel_idx,
4162                            completion_scroll_offset: scroll_off,
4163                            completion_navigated: navigated,
4164                        },
4165                    );
4166                }
4167            }
4168            WidgetMutation::SetChecked {
4169                widget_key,
4170                checked,
4171            } => {
4172                // Toggle checked lives in the spec (not instance
4173                // state). Walk the spec, find the Toggle by key,
4174                // mutate.
4175                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4176                    crate::widgets::set_toggle_checked_in_spec(
4177                        &mut panel.spec,
4178                        &widget_key,
4179                        checked,
4180                    );
4181                }
4182            }
4183            WidgetMutation::SetSelectedIndex { widget_key, index } => {
4184                // List selected_index lives in instance state.
4185                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4186                    let (prev_scroll, prev_index, prev_item_height, prev_user_scrolled) =
4187                        match panel.instance_states.get(&widget_key) {
4188                            Some(crate::widgets::WidgetInstanceState::List {
4189                                scroll_offset,
4190                                selected_index,
4191                                item_height,
4192                                user_scrolled,
4193                            }) => (
4194                                *scroll_offset,
4195                                *selected_index,
4196                                *item_height,
4197                                *user_scrolled,
4198                            ),
4199                            _ => (0, -1, 1, false),
4200                        };
4201                    // Re-pinning the *same* index (which `refreshOpenDialog`
4202                    // does on every repaint) must preserve a user scroll —
4203                    // otherwise a probe-poll refresh would snap the view back
4204                    // to the selection a beat after a mouse scroll. Only an
4205                    // actual selection change re-arms scroll-follows-selection.
4206                    let user_scrolled = prev_user_scrolled && index == prev_index;
4207                    panel.instance_states.insert(
4208                        widget_key,
4209                        crate::widgets::WidgetInstanceState::List {
4210                            scroll_offset: prev_scroll,
4211                            selected_index: index,
4212                            item_height: prev_item_height,
4213                            user_scrolled,
4214                        },
4215                    );
4216                }
4217            }
4218            WidgetMutation::SetCompletions { widget_key, items } => {
4219                // Update completion popup state on a Text widget.
4220                // Non-empty `items` opens the popup and resets the
4221                // host-managed selection to the top candidate;
4222                // empty closes it. The instance state has to
4223                // exist first (a SetCompletions arriving before
4224                // any render is dropped on the floor — Text
4225                // instance state is seeded on first render of
4226                // the spec).
4227                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4228                    if let Some(crate::widgets::WidgetInstanceState::Text {
4229                        completions,
4230                        completion_selected_index,
4231                        completion_scroll_offset,
4232                        completion_navigated,
4233                        ..
4234                    }) = panel.instance_states.get_mut(&widget_key)
4235                    {
4236                        *completions = items;
4237                        *completion_selected_index = 0;
4238                        *completion_scroll_offset = 0;
4239                        // A (re)opened popup is not yet "entered": Tab /
4240                        // Enter act on the form until the user steps in
4241                        // with ↑/↓. (Closing — empty `items` — also
4242                        // resets it, harmlessly.)
4243                        *completion_navigated = false;
4244                    }
4245                }
4246            }
4247            WidgetMutation::SetItems {
4248                widget_key,
4249                items,
4250                item_keys,
4251            } => {
4252                // List items live in the spec.
4253                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4254                    crate::widgets::set_list_items_in_spec(
4255                        &mut panel.spec,
4256                        &widget_key,
4257                        items,
4258                        item_keys,
4259                    );
4260                }
4261            }
4262            WidgetMutation::SetExpandedKeys { widget_key, keys } => {
4263                // Tree expanded_keys lives in instance state.
4264                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4265                    let (prev_scroll, prev_sel) = match panel.instance_states.get(&widget_key) {
4266                        Some(crate::widgets::WidgetInstanceState::Tree {
4267                            scroll_offset,
4268                            selected_index,
4269                            ..
4270                        }) => (*scroll_offset, *selected_index),
4271                        _ => (0, -1),
4272                    };
4273                    let expanded: std::collections::HashSet<String> = keys.into_iter().collect();
4274                    panel.instance_states.insert(
4275                        widget_key,
4276                        crate::widgets::WidgetInstanceState::Tree {
4277                            scroll_offset: prev_scroll,
4278                            selected_index: prev_sel,
4279                            expanded_keys: expanded,
4280                        },
4281                    );
4282                }
4283            }
4284            WidgetMutation::SetCheckedKeys {
4285                widget_key,
4286                checked,
4287                keys,
4288            } => {
4289                // Tree node `checked` lives in the spec (not instance
4290                // state) — the plugin is the source of truth and can
4291                // re-derive the boolean from its model on every spec
4292                // emit. The mutator just stamps the new value into the
4293                // matching nodes so the next render reflects it
4294                // immediately, without round-tripping through the
4295                // plugin.
4296                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4297                    crate::widgets::set_tree_checked_keys_in_spec(
4298                        &mut panel.spec,
4299                        &widget_key,
4300                        checked,
4301                        &keys,
4302                    );
4303                }
4304            }
4305            WidgetMutation::AppendTreeNodes {
4306                widget_key,
4307                new_nodes,
4308                new_item_keys,
4309            } => {
4310                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4311                    crate::widgets::append_tree_nodes_in_spec(
4312                        &mut panel.spec,
4313                        &widget_key,
4314                        new_nodes,
4315                        new_item_keys,
4316                    );
4317                }
4318            }
4319            WidgetMutation::SetRawEntries {
4320                widget_key,
4321                entries,
4322            } => {
4323                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4324                    crate::widgets::set_raw_entries_in_spec(&mut panel.spec, &widget_key, entries);
4325                }
4326            }
4327            WidgetMutation::SetFocusKey { widget_key } => {
4328                // Panel-level focus lives in the registry, not the
4329                // spec. The renderer reads it on the next paint and
4330                // re-clamps to the first tabbable if the key isn't a
4331                // current tabbable, so an unknown key is a safe no-op.
4332                self.widget_registry.set_focus_key(panel_key, widget_key);
4333            }
4334        }
4335
4336        // Re-render with the mutated state. `rerender_widget_panel`
4337        // reads the registry's current spec + instance state and
4338        // pushes the result through the buffer.
4339        self.rerender_widget_panel(panel_key);
4340    }
4341
4342    fn handle_unmount_widget_panel(&mut self, panel_key: &crate::widgets::PanelKey) {
4343        match self.widget_registry.unmount(panel_key) {
4344            Some(buffer_id) => {
4345                tracing::debug!(
4346                    "Unmounted widget panel {} (was rendering into {:?})",
4347                    panel_key,
4348                    buffer_id
4349                );
4350                // Buffer lifetime is owned by the plugin (it created the
4351                // virtual buffer before mounting). The plugin is
4352                // responsible for closing/clearing it; we only forget our
4353                // panel state.
4354            }
4355            None => {
4356                tracing::debug!("UnmountWidgetPanel for unknown panel {} ignored", panel_key);
4357            }
4358        }
4359    }
4360
4361    fn handle_mount_floating_widget(
4362        &mut self,
4363        panel_key: crate::widgets::PanelKey,
4364        spec: fresh_core::api::WidgetSpec,
4365        width_pct: u8,
4366        height_pct: u8,
4367        as_dock: bool,
4368        focus_marker: bool,
4369    ) {
4370        let width_pct = width_pct.clamp(1, 100);
4371        let height_pct = height_pct.clamp(1, 100);
4372        // The dock mounts into its own slot so it coexists with a
4373        // centered modal; everything else is a centered overlay.
4374        let slot = if as_dock {
4375            super::PanelSlot::Dock
4376        } else {
4377            super::PanelSlot::Floating
4378        };
4379        let buffer_id = slot.buffer_id();
4380        // A centered modal owns the keyboard: blur a focused dock so the
4381        // two slots never both claim input. Without this, a dock key
4382        // handler (e.g. its Esc→blur) would greedily consume keys the
4383        // modal deferred to its own mode bindings, stranding the modal
4384        // open. Fires the dock's `blur` widget_event so the owning plugin
4385        // can mirror the state. Does nothing when the dock isn't focused.
4386        if !as_dock && self.dock.as_ref().is_some_and(|f| f.focused) {
4387            self.blur_floating_panel(super::PanelSlot::Dock);
4388        }
4389        let placement = if as_dock {
4390            let width = self
4391                .dock_width
4392                .unwrap_or(32)
4393                .clamp(10, self.terminal_width.max(20).saturating_sub(20).max(10));
4394            super::PanelPlacement::LeftDock { width_cols: width }
4395        } else {
4396            super::PanelPlacement::Centered
4397        };
4398        if let Some(existing) = self.panel_opt_mut(slot).take() {
4399            if existing.panel_key != panel_key {
4400                let _ = self.widget_registry.unmount(&existing.panel_key);
4401            }
4402        }
4403        *self.panel_opt_mut(slot) = Some(FloatingWidgetState {
4404            panel_key: panel_key.clone(),
4405            width_pct,
4406            height_pct,
4407            placement,
4408            focused: true,
4409            entries: Vec::new(),
4410            focus_cursor: None,
4411            embeds: Vec::new(),
4412            overlays: Vec::new(),
4413            scroll_regions: Vec::new(),
4414            scrollbar_tracks: Vec::new(),
4415            scrollbar_mouse: Default::default(),
4416            scrollbar_drag_key: None,
4417            last_inner_rect: None,
4418            scrollbar_hover_zones: Vec::new(),
4419            scrollbar_zone_hovered: false,
4420            fullscreen: false,
4421            focus_marker,
4422        });
4423        let prev = std::collections::HashMap::new();
4424        let prev_focus = String::new();
4425        let panel_width = self.floating_panel_inner_width(slot);
4426        let out = super::widget_runtime::render_floating_spec(
4427            focus_marker,
4428            &spec,
4429            &prev,
4430            &prev_focus,
4431            panel_width,
4432        );
4433        let focus_cursor = out.focus_cursor;
4434        let entries = out.entries;
4435        let embeds = out.embeds;
4436        let overlays = out.overlays;
4437        let scroll_regions = out.scroll_regions;
4438        self.widget_registry.mount(
4439            panel_key.clone(),
4440            buffer_id,
4441            spec,
4442            out.hits,
4443            out.instance_states,
4444            out.focus_key,
4445            out.tabbable,
4446        );
4447        if let Some(fwp) = self.panel_mut(slot) {
4448            fwp.entries = entries;
4449            fwp.focus_cursor = focus_cursor;
4450            fwp.embeds = embeds;
4451            fwp.overlays = overlays;
4452            fwp.scroll_regions = scroll_regions;
4453        }
4454        tracing::debug!(
4455            "Mounted floating widget panel {} ({}%x{}%)",
4456            panel_key,
4457            width_pct,
4458            height_pct
4459        );
4460
4461        // Mounting a panel as the left dock carves a full-height column out
4462        // of the chrome. Run the single layout funnel so terminals and
4463        // viewports reflow to the post-dock width right away (a centered
4464        // panel leaves `dock_cols` at 0, so this is a cheap no-op there).
4465        if as_dock {
4466            self.relayout();
4467        }
4468    }
4469
4470    fn handle_update_floating_widget(
4471        &mut self,
4472        panel_key: &crate::widgets::PanelKey,
4473        spec: fresh_core::api::WidgetSpec,
4474    ) {
4475        let Some(slot) = self.slot_of_panel(panel_key) else {
4476            tracing::debug!(
4477                "UpdateFloatingWidget for unknown / mismatched panel {} ignored",
4478                panel_key
4479            );
4480            return;
4481        };
4482        let prev = self
4483            .widget_registry
4484            .instance_states(panel_key)
4485            .cloned()
4486            .unwrap_or_default();
4487        let prev_focus = self
4488            .widget_registry
4489            .focus_key(panel_key)
4490            .map(|s| s.to_string())
4491            .unwrap_or_default();
4492        let panel_width = self.floating_panel_inner_width(slot);
4493        let focus_marker = self.panel(slot).map(|f| f.focus_marker).unwrap_or(false);
4494        let out = super::widget_runtime::render_floating_spec(
4495            focus_marker,
4496            &spec,
4497            &prev,
4498            &prev_focus,
4499            panel_width,
4500        );
4501        let focus_cursor = out.focus_cursor;
4502        let entries = out.entries;
4503        let embeds = out.embeds;
4504        let overlays = out.overlays;
4505        let scroll_regions = out.scroll_regions;
4506        if self
4507            .widget_registry
4508            .update(
4509                panel_key,
4510                spec,
4511                out.hits,
4512                out.instance_states,
4513                out.focus_key,
4514                out.tabbable,
4515            )
4516            .is_err()
4517        {
4518            tracing::debug!(
4519                "UpdateFloatingWidget for unknown panel {} ignored (not in registry)",
4520                panel_key
4521            );
4522            return;
4523        }
4524        if let Some(fwp) = self.panel_mut(slot) {
4525            fwp.entries = entries;
4526            fwp.focus_cursor = focus_cursor;
4527            fwp.embeds = embeds;
4528            fwp.overlays = overlays;
4529            fwp.scroll_regions = scroll_regions;
4530        }
4531    }
4532
4533    fn handle_unmount_floating_widget(&mut self, panel_key: &crate::widgets::PanelKey) {
4534        let Some(slot) = self.slot_of_panel(panel_key) else {
4535            tracing::debug!(
4536                "UnmountFloatingWidget for unknown / mismatched panel {} ignored",
4537                panel_key
4538            );
4539            return;
4540        };
4541        *self.panel_opt_mut(slot) = None;
4542        let _ = self.widget_registry.unmount(panel_key);
4543        // Hiding the left dock frees its full-height column. The next
4544        // frame's `compute_dock_split` already lays the chrome back out
4545        // full-width (and the early command drain in `render` makes that
4546        // happen in the *same* frame as the unmount), so the layout is
4547        // correct — but the freed strip can still show stale glyphs from
4548        // the old dock until something repaints those cells. Force a full
4549        // clear+redraw so the reclaim is unconditional on every terminal,
4550        // mirroring how a resize relayout clears. Gated to the dock slot:
4551        // a centered modal overlays the full-width chrome without carving
4552        // it, so clearing on its close would only cause a visible flicker.
4553        if slot == super::PanelSlot::Dock {
4554            self.request_full_redraw();
4555        }
4556        // Restore the active window's visible terminal PTYs to their
4557        // dive-view split rects. The orchestrator picker's preview
4558        // pane shrinks PTYs to the embed size on every frame while
4559        // it's up (see `render_session_preview_into_rect`); when the
4560        // picker closes onto the *same* session the user was
4561        // previewing, `set_active_window` short-circuits because the
4562        // active pointer didn't move, and the shrink-down never gets
4563        // undone — top / vim / etc. keep drawing at the embed's ~15
4564        // rows. Resizing here on every panel unmount restores the
4565        // full dive-view dimensions; for panels that didn't preview
4566        // anything (the new-session form, plugin overlays) this is a
4567        // cheap no-op because the PTY sizes already match. Unmounting the
4568        // dock also frees its column, so route through the single layout
4569        // funnel: it re-derives `dock_cols` (now 0 for a dock unmount) and
4570        // reflows every window's terminals + viewports to the reclaimed width.
4571        self.relayout();
4572        tracing::debug!("Unmounted floating widget panel {}", panel_key);
4573    }
4574
4575    /// Apply a `FloatingPanelControl` op. No-op if the panel id
4576    /// doesn't match the mounted floating panel.
4577    fn handle_floating_panel_control(
4578        &mut self,
4579        panel_key: &crate::widgets::PanelKey,
4580        op: &str,
4581        arg: f64,
4582    ) {
4583        let Some(slot) = self.slot_of_panel(panel_key) else {
4584            tracing::warn!("FloatingPanelControl for unknown/mismatched panel {panel_key} ignored");
4585            return;
4586        };
4587        // `blur` fires a widget_event, so handle it before borrowing the
4588        // panel — it reborrows `self` via the shared helper.
4589        if op == "blur" {
4590            self.blur_floating_panel(slot);
4591            return;
4592        }
4593        // Clamp the dock width relative to the terminal so it can never
4594        // swallow the whole chrome. Read before the &mut borrow below.
4595        // A user-dragged width (`dock_width`) overrides the plugin's
4596        // default so the resize survives toggling the dock off/on.
4597        let max_cols = self.terminal_width.max(20).saturating_sub(20).max(10);
4598        let persisted = self.dock_width;
4599        let Some(fwp) = self.panel_mut(slot) else {
4600            return;
4601        };
4602        // Whether this op changed the chrome geometry (dock width/placement),
4603        // so we know to re-derive the layout once the `fwp` borrow ends.
4604        let geometry_changed = match op {
4605            "dock" => {
4606                let requested = persisted.unwrap_or(arg as u16);
4607                let width_cols = requested.clamp(10, max_cols);
4608                fwp.placement = super::PanelPlacement::LeftDock { width_cols };
4609                fwp.focused = true;
4610                true
4611            }
4612            // Update the dock's width WITHOUT touching focus — used by the
4613            // plugin to make the dock responsive (re-issued on terminal
4614            // resize). Unlike "dock" this never steals keyboard focus back
4615            // from the editor, and it's a no-op unless the panel is already
4616            // docked. A user-dragged width still wins (persisted override).
4617            "dock_width" => {
4618                if let super::PanelPlacement::LeftDock { .. } = fwp.placement {
4619                    let requested = persisted.unwrap_or(arg as u16);
4620                    let width_cols = requested.clamp(10, max_cols);
4621                    fwp.placement = super::PanelPlacement::LeftDock { width_cols };
4622                    true
4623                } else {
4624                    false
4625                }
4626            }
4627            "center" => {
4628                fwp.placement = super::PanelPlacement::Centered;
4629                fwp.focused = true;
4630                true
4631            }
4632            // Place the panel as an unobtrusive content-sized popup anchored
4633            // at a screen cell (a right-click context menu). The (x, y) cell
4634            // is packed into the single `f64` arg as `y << 16 | x` — both fit
4635            // a u16 and the sum is exact in `f64`. No chrome-geometry change
4636            // (the dock/editor layout is untouched), so no relayout.
4637            "anchor" => {
4638                let packed = arg.max(0.0) as u64;
4639                let x = (packed & 0xFFFF) as u16;
4640                let y = ((packed >> 16) & 0xFFFF) as u16;
4641                fwp.placement = super::PanelPlacement::Anchored { x, y };
4642                fwp.focused = true;
4643                fwp.fullscreen = false;
4644                false
4645            }
4646            "focus" => {
4647                fwp.focused = true;
4648                false
4649            }
4650            // Render a centered panel over the whole frame (covering the
4651            // dimmed dock) instead of beside the dock in `chrome_area`.
4652            // `arg != 0` enables it. No chrome-geometry change (the dock
4653            // and editor layout are untouched — only where the modal
4654            // paints), so no relayout; the next frame reads the flag.
4655            "fullscreen" => {
4656                fwp.fullscreen = arg != 0.0;
4657                false
4658            }
4659            other => {
4660                tracing::warn!("FloatingPanelControl: unknown op {other:?}");
4661                false
4662            }
4663        };
4664        // The `fwp` mutable borrow ends above; now that the dock's
4665        // placement/width is settled, run the single layout funnel so
4666        // terminals, viewports and panels all reflow to the new chrome.
4667        if geometry_changed {
4668            self.relayout();
4669        }
4670    }
4671
4672    fn handle_get_text_properties_at_cursor(&self, buffer_id: BufferId) {
4673        if let Some(state) = self
4674            .windows
4675            .get(&self.active_window)
4676            .map(|w| &w.buffers)
4677            .expect("active window present")
4678            .get(&buffer_id)
4679        {
4680            let cursor_pos = self
4681                .windows
4682                .get(&self.active_window)
4683                .and_then(|w| w.buffers.splits())
4684                .map(|(_, vs)| vs)
4685                .expect("active window must have a populated split layout")
4686                .values()
4687                .find_map(|vs| vs.buffer_state(buffer_id))
4688                .map(|bs| bs.cursors.primary().position)
4689                .unwrap_or(0);
4690            let properties = state.text_properties.get_at(cursor_pos);
4691            tracing::debug!(
4692                "Text properties at cursor in {:?}: {} properties found",
4693                buffer_id,
4694                properties.len()
4695            );
4696            // TODO: Fire hook with properties data for plugins to consume
4697        }
4698    }
4699
4700    fn handle_set_context(&mut self, name: String, active: bool) {
4701        if active {
4702            self.active_window_mut()
4703                .active_custom_contexts
4704                .insert(name.clone());
4705            tracing::debug!("Set custom context: {}", name);
4706        } else {
4707            self.active_window_mut()
4708                .active_custom_contexts
4709                .remove(&name);
4710            tracing::debug!("Unset custom context: {}", name);
4711        }
4712    }
4713
4714    fn handle_disable_lsp_for_language(&mut self, language: String) {
4715        tracing::info!("Disabling LSP for language: {}", language);
4716        let __active_id = self.active_window;
4717        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
4718            lsp.shutdown_server(&language);
4719            tracing::info!("Stopped LSP server for {}", language);
4720        }
4721        if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
4722            for c in lsp_configs.as_mut_slice() {
4723                c.enabled = false;
4724                c.auto_start = false;
4725            }
4726            tracing::info!("Disabled LSP config for {}", language);
4727        }
4728        if let Err(e) = self.save_config() {
4729            tracing::error!("Failed to save config: {}", e);
4730            self.active_window_mut().status_message = Some(format!(
4731                "LSP disabled for {} (config save failed)",
4732                language
4733            ));
4734        } else {
4735            self.active_window_mut().status_message =
4736                Some(format!("LSP disabled for {}", language));
4737        }
4738        self.active_window_mut().warning_domains.lsp.clear();
4739    }
4740
4741    fn handle_restart_lsp_for_language(&mut self, language: String) {
4742        tracing::info!("Plugin restarting LSP for language: {}", language);
4743        let file_path = self
4744            .active_window()
4745            .buffer_metadata
4746            .get(&self.active_buffer())
4747            .and_then(|meta| meta.file_path().cloned());
4748        let __active_id = self.active_window;
4749        let success = if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
4750            let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
4751            self.active_window_mut().status_message = Some(msg);
4752            ok
4753        } else {
4754            self.active_window_mut().status_message = Some("No LSP manager available".to_string());
4755            false
4756        };
4757        if success {
4758            self.reopen_buffers_for_language(&language);
4759        }
4760    }
4761
4762    fn handle_set_lsp_root_uri(&mut self, language: String, uri: String) {
4763        tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
4764        match uri.parse::<lsp_types::Uri>() {
4765            Ok(parsed_uri) => {
4766                let __active_id = self.active_window;
4767                if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
4768                    let restarted = lsp.set_language_root_uri(&language, parsed_uri);
4769                    if restarted {
4770                        self.active_window_mut().status_message = Some(format!(
4771                            "LSP root updated for {} (restarting server)",
4772                            language
4773                        ));
4774                    } else {
4775                        self.active_window_mut().status_message =
4776                            Some(format!("LSP root set for {}", language));
4777                    }
4778                }
4779            }
4780            Err(e) => {
4781                tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
4782                self.active_window_mut().status_message =
4783                    Some(format!("Invalid LSP root URI: {}", e));
4784            }
4785        }
4786    }
4787
4788    fn handle_create_scroll_sync_group(
4789        &mut self,
4790        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
4791        left_split: SplitId,
4792        right_split: SplitId,
4793    ) {
4794        let success = self
4795            .active_window_mut()
4796            .scroll_sync_manager
4797            .create_group_with_id(group_id, left_split, right_split);
4798        if success {
4799            tracing::debug!(
4800                "Created scroll sync group {} for splits {:?} and {:?}",
4801                group_id,
4802                left_split,
4803                right_split
4804            );
4805        } else {
4806            tracing::warn!(
4807                "Failed to create scroll sync group {} (ID already exists)",
4808                group_id
4809            );
4810        }
4811    }
4812
4813    fn handle_set_scroll_sync_anchors(
4814        &mut self,
4815        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
4816        anchors: Vec<(usize, usize)>,
4817    ) {
4818        use crate::view::scroll_sync::SyncAnchor;
4819        let anchor_count = anchors.len();
4820        let sync_anchors: Vec<SyncAnchor> = anchors
4821            .into_iter()
4822            .map(|(left_line, right_line)| SyncAnchor {
4823                left_line,
4824                right_line,
4825            })
4826            .collect();
4827        self.active_window_mut()
4828            .scroll_sync_manager
4829            .set_anchors(group_id, sync_anchors);
4830        tracing::debug!(
4831            "Set {} anchors for scroll sync group {}",
4832            anchor_count,
4833            group_id
4834        );
4835    }
4836
4837    fn handle_remove_scroll_sync_group(
4838        &mut self,
4839        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
4840    ) {
4841        if self
4842            .active_window_mut()
4843            .scroll_sync_manager
4844            .remove_group(group_id)
4845        {
4846            tracing::debug!("Removed scroll sync group {}", group_id);
4847        } else {
4848            tracing::warn!("Scroll sync group {} not found", group_id);
4849        }
4850    }
4851
4852    fn handle_create_buffer_group(
4853        &mut self,
4854        name: String,
4855        mode: String,
4856        layout_json: String,
4857        request_id: Option<u64>,
4858    ) {
4859        match self.create_buffer_group(name, mode, layout_json) {
4860            Ok(result) => {
4861                if let Some(req_id) = request_id {
4862                    let json = serde_json::to_string(&result).unwrap_or_default();
4863                    self.plugin_manager
4864                        .read()
4865                        .unwrap()
4866                        .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
4867                }
4868            }
4869            Err(e) => {
4870                tracing::error!("Failed to create buffer group: {}", e);
4871            }
4872        }
4873    }
4874
4875    fn handle_send_terminal_input(
4876        &mut self,
4877        terminal_id: crate::services::terminal::TerminalId,
4878        data: String,
4879    ) {
4880        if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
4881            handle.write(data.as_bytes());
4882            tracing::trace!(
4883                "Plugin sent {} bytes to terminal {:?}",
4884                data.len(),
4885                terminal_id
4886            );
4887        } else {
4888            tracing::warn!(
4889                "Plugin tried to send input to non-existent terminal {:?}",
4890                terminal_id
4891            );
4892        }
4893    }
4894
4895    fn handle_close_terminal(&mut self, terminal_id: crate::services::terminal::TerminalId) {
4896        let buffer_to_close = self
4897            .active_window()
4898            .terminal_buffers
4899            .iter()
4900            .find(|(_, &tid)| tid == terminal_id)
4901            .map(|(&bid, _)| bid);
4902        if let Some(buffer_id) = buffer_to_close {
4903            if let Err(e) = self.close_buffer(buffer_id) {
4904                tracing::warn!("Failed to close terminal buffer: {}", e);
4905            }
4906            tracing::info!("Plugin closed terminal {:?}", terminal_id);
4907        } else {
4908            self.active_window_mut().terminal_manager.close(terminal_id);
4909            tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
4910        }
4911    }
4912
4913    /// Fan `signal` out to every process group the window
4914    /// identified by `id` is tracking. The window's authority-
4915    /// configured signaller (see `app/window/process_group.rs`)
4916    /// decides how the signal is delivered. Failures from
4917    /// individual groups land in the tracing log so a partial
4918    /// failure surfaces without aborting the rest of the
4919    /// stop flow.
4920    fn handle_signal_window(&mut self, id: fresh_core::WindowId, signal: &str) {
4921        let Some(window) = self.windows.get_mut(&id) else {
4922            tracing::warn!("Plugin SignalWindow targeted unknown window {:?}", id);
4923            return;
4924        };
4925        let results = window.process_groups.signal_all(signal);
4926        for (entry, result) in results {
4927            match result {
4928                Ok(true) => tracing::info!(
4929                    "SignalWindow {:?}: {} → pid {} ({})",
4930                    id,
4931                    signal,
4932                    entry.leader_pid,
4933                    entry.label
4934                ),
4935                Ok(false) => tracing::debug!(
4936                    "SignalWindow {:?}: pid {} ({}) already exited",
4937                    id,
4938                    entry.leader_pid,
4939                    entry.label
4940                ),
4941                Err(e) => tracing::warn!(
4942                    "SignalWindow {:?}: pid {} ({}): {}",
4943                    id,
4944                    entry.leader_pid,
4945                    entry.label,
4946                    e
4947                ),
4948            }
4949        }
4950    }
4951}
4952
4953/// Clamp a plugin-requested `[start, end)` text range to a buffer's live
4954/// length.
4955///
4956/// `getBufferText` callers size `end` from `getBufferLength`, which reads a
4957/// state snapshot that lags the authoritative buffer. When the buffer shrinks
4958/// in between (concurrent editor + external-process edits), the requested end
4959/// briefly exceeds the live length. Returning the available text is the right
4960/// behaviour — the plugin recomputes on the next change event — so clamp
4961/// instead of rejecting. `start` is pinned to `end` so an over-large start
4962/// yields an empty range rather than `start > end`.
4963fn clamp_buffer_text_range(start: usize, end: usize, len: usize) -> (usize, usize) {
4964    let end = end.min(len);
4965    let start = start.min(end);
4966    (start, end)
4967}
4968
4969#[cfg(test)]
4970mod tests {
4971    //! Focused tests for the SpawnHostProcess kill mechanism.
4972    //!
4973    //! These don't exercise the full `handle_plugin_command` dispatcher
4974    //! (which would require scaffolding an Editor with a real tokio
4975    //! runtime and async_bridge); they replicate the inner
4976    //! `tokio::select!` pattern directly on a real subprocess. A
4977    //! regression in the select arms or in the kill-then-wait
4978    //! sequencing would reproduce here.
4979    //!
4980    //! The dispatcher-level integration coverage comes from the e2e
4981    //! attach-cancel test in `tests/e2e/` — this unit test is the
4982    //! lower-level pin.
4983    use tokio::io::{AsyncReadExt, BufReader};
4984    use tokio::process::Command as TokioCommand;
4985    use tokio::time::{timeout, Duration};
4986
4987    /// A long-sleep child that runs `tokio::select! { wait | kill_rx }`
4988    /// terminates when the kill channel fires, and the terminal exit
4989    /// code reflects signal termination (non-zero / None).
4990    ///
4991    /// Spawns `sleep` directly rather than through `sh -c` so SIGKILL
4992    /// reaches the process whose pipe our reader futures hold —
4993    /// `sh -c sleep` leaks the sleep child on SIGKILL (Q-C2), the
4994    /// pipe stays open, and the reader future hangs. That's a
4995    /// deliberate known limitation of start_kill; this test
4996    /// exercises the clean path.
4997    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4998    async fn kill_via_oneshot_terminates_long_running_child() {
4999        let mut cmd = TokioCommand::new("sleep");
5000        cmd.args(["30"]);
5001        cmd.stdout(std::process::Stdio::piped());
5002        cmd.stderr(std::process::Stdio::piped());
5003
5004        let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
5005        let pid = child.id().expect("child has a pid");
5006
5007        let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
5008        let stdout_pipe = child.stdout.take();
5009        let stderr_pipe = child.stderr.take();
5010
5011        let stdout_fut = async {
5012            let mut buf = String::new();
5013            if let Some(s) = stdout_pipe {
5014                #[allow(clippy::let_underscore_must_use)]
5015                let _ = BufReader::new(s).read_to_string(&mut buf).await;
5016            }
5017            buf
5018        };
5019        let stderr_fut = async {
5020            let mut buf = String::new();
5021            if let Some(s) = stderr_pipe {
5022                #[allow(clippy::let_underscore_must_use)]
5023                let _ = BufReader::new(s).read_to_string(&mut buf).await;
5024            }
5025            buf
5026        };
5027        let wait_fut = async {
5028            tokio::select! {
5029                status = child.wait() => {
5030                    status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
5031                }
5032                _ = &mut kill_rx => {
5033                    #[allow(clippy::let_underscore_must_use)]
5034                    let _ = child.start_kill();
5035                    child
5036                        .wait()
5037                        .await
5038                        .map(|s| s.code().unwrap_or(-1))
5039                        .unwrap_or(-1)
5040                }
5041            }
5042        };
5043
5044        // Give the shell a moment to install itself — firing kill
5045        // against an not-yet-existent child is still valid (SIGKILL
5046        // to a zombie is a no-op) but we want to actually exercise
5047        // the running-child path.
5048        tokio::time::sleep(Duration::from_millis(50)).await;
5049        kill_tx.send(()).expect("kill channel send");
5050
5051        let result = timeout(Duration::from_secs(5), async {
5052            tokio::join!(stdout_fut, stderr_fut, wait_fut)
5053        })
5054        .await;
5055
5056        let (_stdout, _stderr, exit_code) = result.expect(
5057            "kill path must resolve within 5s — if this times out the \
5058             select! arm order or kill-then-wait logic is broken",
5059        );
5060        // The cross-platform invariant is "the child did not complete
5061        // its 30s sleep" — i.e. the exit code is non-success. Platform
5062        // specifics:
5063        //   - Unix: `start_kill()` sends SIGKILL; `ExitStatus::code()`
5064        //     returns None for signal-terminated processes, which our
5065        //     dispatcher maps to -1 via `.unwrap_or(-1)`.
5066        //   - Windows: `start_kill()` calls `TerminateProcess(..., 1)`;
5067        //     `code()` returns `Some(1)`, mapped to 1 by the same
5068        //     `.unwrap_or(-1)`.
5069        // A successful 30s sleep would yield 0 — that's the
5070        // regression case we're guarding against.
5071        assert_ne!(
5072            exit_code, 0,
5073            "killed child must exit non-success (got 0 — did the \
5074             kill arm fire too late, or did sleep somehow complete?)"
5075        );
5076
5077        // Sanity: on Unix the child must be gone. `kill -0 <pid>`
5078        // returns 0 iff the process still exists; we expect non-zero
5079        // (No such process) after wait(). This catches a zombie /
5080        // leaked child that would indicate we skipped the wait() on
5081        // the kill path. Skipped on Windows — `kill` isn't available
5082        // and `tasklist` output parsing is more noise than signal
5083        // for this one-shot check; the wait() having returned is
5084        // already evidence of reap there.
5085        #[cfg(unix)]
5086        {
5087            let still_alive = std::process::Command::new("kill")
5088                .args(["-0", &pid.to_string()])
5089                .status()
5090                .map(|s| s.success())
5091                .unwrap_or(false);
5092            assert!(
5093                !still_alive,
5094                "process {pid} must be reaped after wait() — a still-\
5095                 alive check means the kill path leaked the child"
5096            );
5097        }
5098        #[cfg(not(unix))]
5099        {
5100            // Touch `pid` so the unused-variable lint doesn't fire on
5101            // non-Unix builds.
5102            let _ = pid;
5103        }
5104    }
5105
5106    use super::clamp_buffer_text_range;
5107
5108    #[test]
5109    fn clamp_text_range_passes_through_in_bounds() {
5110        assert_eq!(clamp_buffer_text_range(0, 165, 165), (0, 165));
5111        assert_eq!(clamp_buffer_text_range(10, 50, 165), (10, 50));
5112    }
5113
5114    /// The reported regression: `getBufferLength` returned a snapshot
5115    /// length one byte ahead of the live buffer (the file was shrinking
5116    /// under concurrent editor + external edits), so `getBufferText`
5117    /// requested `0..len+1`. Pre-fix this produced "Invalid range
5118    /// 0..165003 for buffer of length 165002"; now the end clamps down.
5119    #[test]
5120    fn clamp_text_range_clamps_stale_end_past_buffer() {
5121        assert_eq!(clamp_buffer_text_range(0, 165_003, 165_002), (0, 165_002));
5122    }
5123
5124    #[test]
5125    fn clamp_text_range_pins_overlarge_start_to_empty() {
5126        // start beyond the live length must not yield start > end.
5127        assert_eq!(clamp_buffer_text_range(200, 250, 165), (165, 165));
5128    }
5129}
5130
5131impl Window {
5132    /// Populate the per-window fields of the plugin state snapshot.
5133    ///
5134    /// Called by `Editor::update_plugin_state_snapshot` while it holds
5135    /// the snapshot write lock. Covers everything that a single Window
5136    /// owns: active buffer/split ids, all this window's buffers (with
5137    /// per-buffer view-mode, compose state, preview flag, split
5138    /// membership), per-buffer cursor positions and text properties,
5139    /// the active buffer's cursors / viewport / selected text, the
5140    /// per-split snapshot list, this window's active-session plugin
5141    /// state, this window's authority label, diagnostics, folding
5142    /// ranges, editor mode, and the per-window plugin view states.
5143    /// Editor-wide fields (clipboard, windows list, config cache,
5144    /// user_config_raw, plugin_global_state) are populated by the
5145    /// Editor coda after this returns.
5146    #[cfg(feature = "plugins")]
5147    pub(crate) fn populate_plugin_state_snapshot(
5148        &mut self,
5149        snapshot: &mut fresh_core::api::EditorStateSnapshot,
5150    ) {
5151        use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
5152
5153        // Rebuild only on registry mutation. Compares the registry's
5154        // monotonic catalog_gen against the last-seen value on the
5155        // snapshot — a single integer check, no allocation, no
5156        // count-mismatch ambiguity between the syntect set and the
5157        // unified catalog.
5158        let current_gen = self.resources.grammar_registry.catalog_gen();
5159        if snapshot.last_grammar_gen != current_gen {
5160            snapshot.available_grammars = self
5161                .resources
5162                .grammar_registry
5163                .available_grammar_info()
5164                .into_iter()
5165                .map(|g| fresh_core::api::GrammarInfoSnapshot {
5166                    name: g.name,
5167                    source: g.source.to_string(),
5168                    file_extensions: g.file_extensions,
5169                    short_name: g.short_name,
5170                })
5171                .collect();
5172            snapshot.last_grammar_gen = current_gen;
5173        }
5174
5175        snapshot.active_buffer_id = self.active_buffer();
5176
5177        let (mgr_ref, vs_ref) = self
5178            .buffers
5179            .splits()
5180            .expect("active window must have a populated split layout");
5181        let active_split = mgr_ref.active_split();
5182        snapshot.active_split_id = active_split.0 .0;
5183
5184        // Clear and update buffer info
5185        snapshot.buffers.clear();
5186        snapshot.buffer_saved_diffs.clear();
5187        snapshot.buffer_cursor_positions.clear();
5188        snapshot.buffer_text_properties.clear();
5189
5190        let active_vs_opt = vs_ref.get(&active_split);
5191        for (buffer_id, state) in &self.buffers {
5192            let is_virtual = self
5193                .buffer_metadata
5194                .get(buffer_id)
5195                .map(|m| m.is_virtual())
5196                .unwrap_or(false);
5197            // Report the ACTIVE split's view_mode so plugins can distinguish
5198            // which mode the user is currently in. Separately, report whether
5199            // ANY split has compose mode so plugins can maintain decorations
5200            // for compose-mode splits even when a source-mode split is active.
5201            let view_mode = active_vs_opt
5202                .and_then(|vs| vs.buffer_state(*buffer_id))
5203                .map(|bs| match bs.view_mode {
5204                    crate::state::ViewMode::Source => "source",
5205                    crate::state::ViewMode::PageView => "compose",
5206                })
5207                .unwrap_or("source");
5208            let compose_width = active_vs_opt
5209                .and_then(|vs| vs.buffer_state(*buffer_id))
5210                .and_then(|bs| bs.compose_width);
5211            let is_composing_in_any_split = vs_ref.values().any(|vs| {
5212                vs.buffer_state(*buffer_id)
5213                    .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
5214                    .unwrap_or(false)
5215            });
5216            let is_preview = self.is_buffer_preview(*buffer_id);
5217            // Which splits currently hold this buffer — lets plugins
5218            // implement "focus existing if visible, else open new"
5219            // without tracking split ids across editor restarts
5220            // (the restart reassigns them). SplitManager has the
5221            // authoritative map; we just mirror it.
5222            let splits: Vec<fresh_core::SplitId> = mgr_ref
5223                .splits_for_buffer(*buffer_id)
5224                .into_iter()
5225                .map(|leaf_id| leaf_id.0)
5226                .collect();
5227            let buffer_info = BufferInfo {
5228                id: *buffer_id,
5229                path: state.buffer.file_path().map(|p| p.to_path_buf()),
5230                modified: state.buffer.is_modified(),
5231                length: state.buffer.len(),
5232                is_virtual,
5233                editing_disabled: state.editing_disabled,
5234                view_mode: view_mode.to_string(),
5235                is_composing_in_any_split,
5236                compose_width,
5237                language: state.language.clone(),
5238                is_preview,
5239                splits,
5240            };
5241            snapshot.buffers.insert(*buffer_id, buffer_info);
5242
5243            let diff = {
5244                let diff = state.buffer.diff_since_saved();
5245                BufferSavedDiff {
5246                    equal: diff.equal,
5247                    byte_ranges: diff.byte_ranges.clone(),
5248                }
5249            };
5250            snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
5251
5252            // Regular buffers live in exactly one split's keyed_states.
5253            // Panel (hidden) buffers natively live inside a group's inner
5254            // split — but the close-buffer path can leave a *shadow*
5255            // entry in the group's host split (from `switch_buffer`'s
5256            // auto-insert, kept to preserve the
5257            // `active_buffer ∈ keyed_states` invariant). For hidden
5258            // buffers we therefore skip group-host splits and pick the
5259            // inner split, which is the authoritative home.
5260            let is_hidden = self
5261                .buffer_metadata
5262                .get(buffer_id)
5263                .is_some_and(|m| m.hidden_from_tabs);
5264            let source_split = vs_ref.iter().find(|(split_id, vs)| {
5265                vs.keyed_states.contains_key(buffer_id)
5266                    && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
5267            });
5268            let cursor_pos = source_split
5269                .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
5270                .map(|bs| bs.cursors.primary().position)
5271                .unwrap_or(0);
5272            tracing::trace!(
5273                "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
5274                buffer_id,
5275                cursor_pos,
5276                source_split.map(|(id, _)| *id),
5277            );
5278            snapshot
5279                .buffer_cursor_positions
5280                .insert(*buffer_id, cursor_pos);
5281
5282            // Store text properties if this buffer has any
5283            if !state.text_properties.is_empty() {
5284                snapshot
5285                    .buffer_text_properties
5286                    .insert(*buffer_id, state.text_properties.all().to_vec());
5287            }
5288        }
5289
5290        // Update cursor information for active buffer.
5291        //
5292        // Use `effective_active_pair()` for the split id rather than
5293        // the split manager's outer `active_split()`. When the active
5294        // split holds a buffer-group tab, the user's keystrokes (and
5295        // therefore the meaningful cursor) live in the focused inner
5296        // panel's leaf — `focused_group_leaf` — not the outer leaf.
5297        // Reading the outer's cursor here would publish (0, 0) into
5298        // the snapshot while the user is editing the inner panel,
5299        // which is what `editor.getCursorPosition()` then sees.
5300        let active_buf_id = snapshot.active_buffer_id;
5301        let active_split_id = self.effective_active_pair().0;
5302        self.buffers
5303            .with_all_mut(|buffers_mut, mgr, vs_map| {
5304                let _ = mgr; // active_split_id was computed above
5305                if let Some(active_vs) = vs_map.get(&active_split_id) {
5306                    // Primary cursor (from SplitViewState)
5307                    let active_cursors = &active_vs.cursors;
5308                    let primary = active_cursors.primary();
5309                    let primary_position = primary.position;
5310                    let primary_selection = primary.selection_range();
5311
5312                    // Resolve a byte offset to its 0-indexed line, but only when the
5313                    // active buffer has a line index. Huge files load without line
5314                    // metadata (`line_count() == None`); reporting `0` there would be
5315                    // a lie, so we surface `None` instead — the same guard the
5316                    // viewport's `top_line` uses below.
5317                    let line_of = |offset: usize| -> Option<usize> {
5318                        buffers_mut.get(&active_buf_id).and_then(|state| {
5319                            if state.buffer.line_count().is_some() {
5320                                Some(state.buffer.get_line_number(offset))
5321                            } else {
5322                                None
5323                            }
5324                        })
5325                    };
5326
5327                    snapshot.primary_cursor = Some(CursorInfo {
5328                        position: primary_position,
5329                        selection: primary_selection.clone(),
5330                        line: line_of(primary_position),
5331                    });
5332
5333                    snapshot.all_cursors = active_cursors
5334                        .iter()
5335                        .map(|(_, cursor)| CursorInfo {
5336                            position: cursor.position,
5337                            selection: cursor.selection_range(),
5338                            line: line_of(cursor.position),
5339                        })
5340                        .collect();
5341
5342                    // Selected text from primary cursor (for clipboard plugin)
5343                    if let Some(range) = primary_selection {
5344                        if let Some(active_state) = buffers_mut.get_mut(&active_buf_id) {
5345                            snapshot.selected_text =
5346                                Some(active_state.get_text_range(range.start, range.end));
5347                        }
5348                    }
5349
5350                    // Viewport — get from SplitViewState (the authoritative source)
5351                    let top_line = buffers_mut.get(&active_buf_id).and_then(|state| {
5352                        if state.buffer.line_count().is_some() {
5353                            Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
5354                        } else {
5355                            None
5356                        }
5357                    });
5358                    snapshot.viewport = Some(ViewportInfo {
5359                        top_byte: active_vs.viewport.top_byte,
5360                        top_line,
5361                        left_column: active_vs.viewport.left_column,
5362                        width: active_vs.viewport.width,
5363                        height: active_vs.viewport.height,
5364                    });
5365                } else {
5366                    snapshot.primary_cursor = None;
5367                    snapshot.all_cursors.clear();
5368                    snapshot.viewport = None;
5369                    snapshot.selected_text = None;
5370                }
5371
5372                // Per-split snapshot
5373                snapshot.splits.clear();
5374                for (leaf_id, vs) in vs_map.iter() {
5375                    let buf_id = vs.active_buffer;
5376                    let top_line = buffers_mut.get(&buf_id).and_then(|state| {
5377                        if state.buffer.line_count().is_some() {
5378                            Some(state.buffer.get_line_number(vs.viewport.top_byte))
5379                        } else {
5380                            None
5381                        }
5382                    });
5383                    snapshot.splits.push(fresh_core::api::SplitSnapshot {
5384                        split_id: leaf_id.0 .0,
5385                        buffer_id: buf_id,
5386                        viewport: ViewportInfo {
5387                            top_byte: vs.viewport.top_byte,
5388                            top_line,
5389                            left_column: vs.viewport.left_column,
5390                            width: vs.viewport.width,
5391                            height: vs.viewport.height,
5392                        },
5393                    });
5394                }
5395            })
5396            .expect("active window must have a populated split layout");
5397
5398        // Mirror the active session's plugin_state into the snapshot
5399        // so getWindowState reads cheaply. Cloning is fine here: the
5400        // per-session state is small; plugins that store megabyte-
5401        // scale blobs in setWindowState will see proportional snapshot-
5402        // update cost, which is the desired feedback signal.
5403        snapshot.active_session_plugin_states = self.plugin_state.clone();
5404        // `authority_label` is populated by the Editor coda — see the
5405        // comment there for why it can't come from `self.resources`.
5406
5407        // Update LSP diagnostics / folding ranges: Arc refcount bumps.
5408        snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
5409        snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
5410
5411        // Update editor mode (for vi mode and other modal editing)
5412        snapshot.editor_mode = self.editor_mode.clone();
5413
5414        // Update plugin view states from active split's BufferViewState.plugin_state.
5415        // If the active split changed, fully repopulate. Otherwise, merge
5416        // using or_insert to preserve JS-side write-through entries that
5417        // haven't round-tripped through the command channel yet.
5418        let active_split_id_u64 = active_split_id.0 .0;
5419        let split_changed = snapshot.plugin_view_states_split != active_split_id_u64;
5420        if split_changed {
5421            snapshot.plugin_view_states.clear();
5422            snapshot.plugin_view_states_split = active_split_id_u64;
5423        }
5424
5425        // Clean up entries for buffers that are no longer open
5426        {
5427            let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
5428            snapshot
5429                .plugin_view_states
5430                .retain(|bid, _| open_bids.contains(bid));
5431        }
5432
5433        // Merge from Rust-side plugin_state (source of truth for persisted state)
5434        if let Some(vs_map) = self.buffers.split_view_states() {
5435            if let Some(active_vs) = vs_map.get(&active_split_id) {
5436                for (buffer_id, buf_state) in &active_vs.keyed_states {
5437                    if !buf_state.plugin_state.is_empty() {
5438                        let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
5439                        for (key, value) in &buf_state.plugin_state {
5440                            entry.entry(key.clone()).or_insert_with(|| value.clone());
5441                        }
5442                    }
5443                }
5444            }
5445        }
5446
5447        // Update active search state so plugins can query it via hasActiveSearch()
5448        snapshot.has_active_search = self.search_state.is_some();
5449    }
5450}
5451
5452// `editor.httpFetch` downloads stream through `services::http::download_to_file`,
5453// which keeps all ureq/TLS usage in one place (gated by the `http` feature).