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, FLOATING_PANEL_BUFFER_ID};
26
27/// Returns the byte offset of the start (want_end=false) or end (want_end=true)
28/// of `line` (0-indexed) within `content`. Returns `None` when `line` is out of
29/// range. The "end" position is the byte index of the terminating `\n`; for the
30/// last line with no trailing newline it is `buffer_len`.
31fn buffer_line_byte_offset(
32    content: &str,
33    buffer_len: usize,
34    line: usize,
35    want_end: bool,
36) -> Option<usize> {
37    if !want_end && line == 0 {
38        return Some(0);
39    }
40    let mut current_line = 0usize;
41    for (byte_idx, c) in content.char_indices() {
42        if c == '\n' {
43            if want_end && current_line == line {
44                return Some(byte_idx);
45            }
46            current_line += 1;
47            if !want_end && current_line == line {
48                return Some(byte_idx + 1);
49            }
50        }
51    }
52    if want_end && current_line == line {
53        Some(buffer_len)
54    } else {
55        None
56    }
57}
58
59/// Walk a `Tree`'s flat `nodes` and return the absolute indices of
60/// nodes that are currently visible — i.e. every ancestor is in
61/// `expanded`. Mirrors the renderer's filter so dispatcher and
62/// renderer agree on what's selectable.
63/// First `Tree` or `List` widget key in `spec`, scanning in
64/// declaration order. Used by mouse-wheel routing to pick which
65/// widget inside a panel absorbs the scroll.
66fn find_scrollable_widget_key(spec: &fresh_core::api::WidgetSpec) -> Option<String> {
67    use fresh_core::api::WidgetSpec;
68    match spec {
69        WidgetSpec::Tree { key: Some(k), .. } | WidgetSpec::List { key: Some(k), .. }
70            if !k.is_empty() =>
71        {
72            return Some(k.clone());
73        }
74        _ => {}
75    }
76    spec.children().find_map(find_scrollable_widget_key)
77}
78
79fn collect_visible_tree_indices(
80    nodes: &[fresh_core::api::TreeNode],
81    item_keys: &[String],
82    expanded: &std::collections::HashSet<String>,
83) -> Vec<usize> {
84    let mut ancestor_open: Vec<bool> = Vec::new();
85    let mut visible: Vec<usize> = Vec::with_capacity(nodes.len());
86    for (i, node) in nodes.iter().enumerate() {
87        let depth = node.depth as usize;
88        ancestor_open.truncate(depth);
89        if ancestor_open.iter().all(|open| *open) {
90            visible.push(i);
91        }
92        let key = item_keys.get(i).cloned().unwrap_or_default();
93        let is_open = if node.has_children {
94            !key.is_empty() && expanded.contains(&key)
95        } else {
96            true
97        };
98        ancestor_open.push(is_open);
99    }
100    visible
101}
102
103impl Editor {
104    /// Update the plugin state snapshot with current editor state.
105    ///
106    /// Per-window snapshot population (active buffer, splits, view
107    /// states, cursors, diagnostics, folding ranges, plugin view
108    /// states) lives in [`Window::populate_plugin_state_snapshot`].
109    /// This function adds the editor-wide fields that no single Window
110    /// owns (clipboard, the full `windows` list, the memoized config
111    /// JSON cache, `user_config_raw`, and `plugin_global_state`).
112    #[cfg(feature = "plugins")]
113    pub(super) fn update_plugin_state_snapshot(&mut self) {
114        let Some(snapshot_handle) = self.plugin_manager.read().unwrap().state_snapshot_handle()
115        else {
116            return;
117        };
118        let mut snapshot = snapshot_handle.write().unwrap();
119
120        self.active_window_mut()
121            .populate_plugin_state_snapshot(&mut snapshot);
122
123        // Editor-wide fields below — these reach state outside any
124        // single Window.
125
126        snapshot.clipboard = self.clipboard.get_internal().to_string();
127        snapshot.working_dir = self.working_dir().to_path_buf();
128
129        // Total terminal dimensions (full screen, not the active
130        // split's viewport). Plugins read this via `getScreenSize()`
131        // to size floating overlays against the whole terminal.
132        snapshot.terminal_width = self.terminal_width;
133        snapshot.terminal_height = self.terminal_height;
134
135        // Authority label tracks `Editor::authority` (the active
136        // authority). It can't be sourced from `Window::resources.authority`
137        // because `set_boot_authority` replaces `self.authority` by value
138        // — the per-window resource clones still point at the previous
139        // authority handle. Reading from `Editor` keeps the snapshot in
140        // lockstep with the canonical seat.
141        snapshot.authority_label = self.authority.display_label.clone();
142
143        // Surface the active project's Workspace Trust level so plugins that
144        // run repo-controlled work can gate on it.
145        snapshot.workspace_trust_level =
146            self.authority.workspace_trust.level().as_str().to_string();
147        snapshot.env_active = self.authority.env_provider.is_active();
148
149        // Publish the session list so plugins (Orchestrator, etc.)
150        // see updates from createWindow/closeWindow without
151        // a separate notification path. Sorted by id for
152        // deterministic order — `next_window_id` is monotonic
153        // so this is "creation order".
154        let mut session_infos: Vec<fresh_core::api::WindowInfo> = self
155            .windows
156            .values()
157            .map(|s| {
158                let slot = s.plugin_state.get("orchestrator");
159                let project_path = slot
160                    .and_then(|m| m.get("project_path"))
161                    .and_then(|v| v.as_str())
162                    .map(std::path::PathBuf::from);
163                let shared_worktree = slot
164                    .and_then(|m| m.get("shared_worktree"))
165                    .and_then(|v| v.as_bool())
166                    .unwrap_or(false);
167                fresh_core::api::WindowInfo {
168                    id: s.id,
169                    label: s.label.clone(),
170                    root: s.root.clone(),
171                    project_path,
172                    shared_worktree,
173                }
174            })
175            .collect();
176        session_infos.sort_by_key(|s| s.id.0);
177        snapshot.windows = session_infos;
178        snapshot.active_window_id = self.active_window;
179
180        // Reserialize config only when the underlying `Arc<Config>`
181        // pointer has actually moved since the last refresh —
182        // `Arc::ptr_eq` vs `config_snapshot_anchor` is a sound cache
183        // key because the anchor keeps `self.config`'s strong count
184        // at ≥ 2, forcing every `Arc::make_mut` on the editor side
185        // to CoW into a new allocation. On idle (no config mutation),
186        // this branch is skipped entirely and the snapshot update is
187        // a refcount bump.
188        if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
189            let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
190            self.config_cached_json = Arc::new(json);
191            self.config_snapshot_anchor = Arc::clone(&self.config);
192        }
193        snapshot.config = Arc::clone(&self.config_cached_json);
194
195        // Cached raw user config file contents (not merged with defaults).
196        // Lets plugins distinguish user-set from default values.
197        snapshot.user_config = Arc::clone(&self.user_config_raw);
198
199        // Merge plugin global states from Rust-side store.
200        // `or_insert` preserves JS-side write-through entries.
201        for (plugin_name, state_map) in &self.plugin_global_state {
202            let entry = snapshot
203                .plugin_global_states
204                .entry(plugin_name.clone())
205                .or_default();
206            for (key, value) in state_map {
207                entry.entry(key.clone()).or_insert_with(|| value.clone());
208            }
209        }
210    }
211
212    /// Handle a plugin command - dispatches to specialized handlers in plugin_commands module
213    pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
214        match command {
215            // ==================== Text Editing Commands ====================
216            PluginCommand::InsertText {
217                buffer_id,
218                position,
219                text,
220            } => {
221                self.handle_insert_text(buffer_id, position, text);
222            }
223            PluginCommand::DeleteRange { buffer_id, range } => {
224                self.handle_delete_range(buffer_id, range);
225            }
226            PluginCommand::InsertAtCursor { text } => {
227                self.handle_insert_at_cursor(text);
228            }
229            PluginCommand::DeleteSelection => {
230                self.handle_delete_selection();
231            }
232
233            // ==================== Overlay Commands ====================
234            PluginCommand::AddOverlay {
235                buffer_id,
236                namespace,
237                range,
238                options,
239            } => {
240                self.handle_add_overlay(buffer_id, namespace, range, options);
241            }
242            PluginCommand::RemoveOverlay { buffer_id, handle } => {
243                self.handle_remove_overlay(buffer_id, handle);
244            }
245            PluginCommand::ClearAllOverlays { buffer_id } => {
246                self.handle_clear_all_overlays(buffer_id);
247            }
248            PluginCommand::ClearNamespace {
249                buffer_id,
250                namespace,
251            } => {
252                self.handle_clear_namespace(buffer_id, namespace);
253            }
254            PluginCommand::ClearOverlaysInRange {
255                buffer_id,
256                start,
257                end,
258            } => {
259                self.handle_clear_overlays_in_range(buffer_id, start, end);
260            }
261
262            // ==================== Virtual Text Commands ====================
263            PluginCommand::AddVirtualText {
264                buffer_id,
265                virtual_text_id,
266                position,
267                text,
268                color,
269                use_bg,
270                before,
271            } => {
272                self.handle_add_virtual_text(
273                    buffer_id,
274                    virtual_text_id,
275                    position,
276                    text,
277                    color,
278                    use_bg,
279                    before,
280                );
281            }
282            PluginCommand::AddVirtualTextStyled {
283                buffer_id,
284                virtual_text_id,
285                position,
286                text,
287                fg,
288                bg,
289                bold,
290                italic,
291                before,
292            } => {
293                self.handle_add_virtual_text_styled(
294                    buffer_id,
295                    virtual_text_id,
296                    position,
297                    text,
298                    fg,
299                    bg,
300                    bold,
301                    italic,
302                    before,
303                );
304            }
305            PluginCommand::RemoveVirtualText {
306                buffer_id,
307                virtual_text_id,
308            } => {
309                self.handle_remove_virtual_text(buffer_id, virtual_text_id);
310            }
311            PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
312                self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
313            }
314            PluginCommand::ClearVirtualTexts { buffer_id } => {
315                self.handle_clear_virtual_texts(buffer_id);
316            }
317            PluginCommand::AddVirtualLine {
318                buffer_id,
319                position,
320                text,
321                fg_color,
322                bg_color,
323                above,
324                namespace,
325                priority,
326                gutter_glyph,
327                gutter_color,
328                text_overlays,
329            } => {
330                self.handle_add_virtual_line(
331                    buffer_id,
332                    position,
333                    text,
334                    fg_color,
335                    bg_color,
336                    above,
337                    namespace,
338                    priority,
339                    gutter_glyph,
340                    gutter_color,
341                    text_overlays,
342                );
343            }
344            PluginCommand::ClearVirtualTextNamespace {
345                buffer_id,
346                namespace,
347            } => {
348                self.handle_clear_virtual_text_namespace(buffer_id, namespace);
349            }
350
351            // ==================== Conceal Commands ====================
352            PluginCommand::AddConceal {
353                buffer_id,
354                namespace,
355                start,
356                end,
357                replacement,
358            } => {
359                self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
360            }
361            PluginCommand::ClearConcealNamespace {
362                buffer_id,
363                namespace,
364            } => {
365                self.handle_clear_conceal_namespace(buffer_id, namespace);
366            }
367            PluginCommand::ClearConcealsInRange {
368                buffer_id,
369                start,
370                end,
371            } => {
372                self.handle_clear_conceals_in_range(buffer_id, start, end);
373            }
374
375            PluginCommand::AddFold {
376                buffer_id,
377                start,
378                end,
379                placeholder,
380            } => {
381                self.handle_add_fold(buffer_id, start, end, placeholder);
382            }
383            PluginCommand::ClearFolds { buffer_id } => {
384                self.handle_clear_folds(buffer_id);
385            }
386            PluginCommand::SetFoldingRanges { buffer_id, ranges } => {
387                self.handle_set_folding_ranges(buffer_id, ranges);
388            }
389
390            // ==================== Soft Break Commands ====================
391            PluginCommand::AddSoftBreak {
392                buffer_id,
393                namespace,
394                position,
395                indent,
396            } => {
397                self.handle_add_soft_break(buffer_id, namespace, position, indent);
398            }
399            PluginCommand::ClearSoftBreakNamespace {
400                buffer_id,
401                namespace,
402            } => {
403                self.handle_clear_soft_break_namespace(buffer_id, namespace);
404            }
405            PluginCommand::ClearSoftBreaksInRange {
406                buffer_id,
407                start,
408                end,
409            } => {
410                self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
411            }
412
413            // ==================== Menu Commands ====================
414            PluginCommand::AddMenuItem {
415                menu_label,
416                item,
417                position,
418            } => {
419                self.handle_add_menu_item(menu_label, item, position);
420            }
421            PluginCommand::AddMenu { menu, position } => {
422                self.handle_add_menu(menu, position);
423            }
424            PluginCommand::RemoveMenuItem {
425                menu_label,
426                item_label,
427            } => {
428                self.handle_remove_menu_item(menu_label, item_label);
429            }
430            PluginCommand::RemoveMenu { menu_label } => {
431                self.handle_remove_menu(menu_label);
432            }
433
434            // ==================== Split Commands ====================
435            PluginCommand::FocusSplit { split_id } => {
436                self.handle_focus_split(split_id);
437            }
438            PluginCommand::SetSplitBuffer {
439                split_id,
440                buffer_id,
441            } => {
442                self.handle_set_split_buffer(split_id, buffer_id);
443            }
444            PluginCommand::SetSplitScroll { split_id, top_byte } => {
445                self.handle_set_split_scroll(split_id, top_byte);
446            }
447            PluginCommand::RequestHighlights {
448                buffer_id,
449                range,
450                request_id,
451            } => {
452                self.handle_request_highlights(buffer_id, range, request_id);
453            }
454            PluginCommand::CloseSplit { split_id } => {
455                self.handle_close_split(split_id);
456            }
457            PluginCommand::SetSplitRatio { split_id, ratio } => {
458                self.handle_set_split_ratio(split_id, ratio);
459            }
460            PluginCommand::SetSplitLabel { split_id, label } => {
461                self.windows
462                    .get_mut(&self.active_window)
463                    .and_then(|w| w.split_manager_mut())
464                    .expect("active window must have a populated split layout")
465                    .set_label(LeafId(split_id), label);
466            }
467            PluginCommand::ClearSplitLabel { split_id } => {
468                self.windows
469                    .get_mut(&self.active_window)
470                    .and_then(|w| w.split_manager_mut())
471                    .expect("active window must have a populated split layout")
472                    .clear_label(split_id);
473            }
474            PluginCommand::GetSplitByLabel { label, request_id } => {
475                self.handle_get_split_by_label(label, request_id);
476            }
477            PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
478                self.handle_distribute_splits_evenly();
479            }
480            PluginCommand::SetBufferCursor {
481                buffer_id,
482                position,
483            } => {
484                self.handle_set_buffer_cursor(buffer_id, position);
485            }
486            PluginCommand::SetBufferShowCursors { buffer_id, show } => {
487                self.handle_set_buffer_show_cursors(buffer_id, show);
488            }
489
490            // ==================== View/Layout Commands ====================
491            PluginCommand::SetLayoutHints {
492                buffer_id,
493                split_id,
494                range: _,
495                hints,
496            } => {
497                self.handle_set_layout_hints(buffer_id, split_id, hints);
498            }
499            PluginCommand::SetLineNumbers { buffer_id, enabled } => {
500                self.handle_set_line_numbers(buffer_id, enabled);
501            }
502            PluginCommand::SetViewMode { buffer_id, mode } => {
503                self.handle_set_view_mode(buffer_id, &mode);
504            }
505            PluginCommand::SetLineWrap {
506                buffer_id,
507                split_id,
508                enabled,
509            } => {
510                self.handle_set_line_wrap(buffer_id, split_id, enabled);
511            }
512            PluginCommand::SubmitViewTransform {
513                buffer_id,
514                split_id,
515                payload,
516            } => {
517                self.handle_submit_view_transform(buffer_id, split_id, payload);
518            }
519            PluginCommand::ClearViewTransform {
520                buffer_id: _,
521                split_id,
522            } => {
523                self.handle_clear_view_transform(split_id);
524            }
525            PluginCommand::SetViewState {
526                buffer_id,
527                key,
528                value,
529            } => {
530                self.handle_set_view_state(buffer_id, key, value);
531            }
532            PluginCommand::SetGlobalState {
533                plugin_name,
534                key,
535                value,
536            } => {
537                self.handle_set_global_state(plugin_name, key, value);
538            }
539            PluginCommand::SetWindowState {
540                plugin_name,
541                key,
542                value,
543            } => {
544                self.handle_set_session_state(plugin_name, key, value);
545            }
546            PluginCommand::RefreshLines { buffer_id } => {
547                self.handle_refresh_lines(buffer_id);
548            }
549            PluginCommand::RefreshAllLines => {
550                self.handle_refresh_all_lines();
551            }
552            PluginCommand::HookCompleted { .. } => {
553                // Sentinel processed in render loop; no-op if encountered elsewhere.
554            }
555            PluginCommand::SetLineIndicator {
556                buffer_id,
557                line,
558                namespace,
559                symbol,
560                color,
561                priority,
562            } => {
563                self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
564            }
565            PluginCommand::SetLineIndicators {
566                buffer_id,
567                lines,
568                namespace,
569                symbol,
570                color,
571                priority,
572            } => {
573                self.handle_set_line_indicators(
574                    buffer_id, lines, namespace, symbol, color, priority,
575                );
576            }
577            PluginCommand::ClearLineIndicators {
578                buffer_id,
579                namespace,
580            } => {
581                self.handle_clear_line_indicators(buffer_id, namespace);
582            }
583            PluginCommand::SetFileExplorerDecorations {
584                namespace,
585                decorations,
586            } => {
587                self.active_window_mut()
588                    .handle_set_file_explorer_decorations(namespace, decorations);
589            }
590            PluginCommand::ClearFileExplorerDecorations { namespace } => {
591                self.active_window_mut()
592                    .handle_clear_file_explorer_decorations(&namespace);
593            }
594
595            // ==================== Status/Prompt Commands ====================
596            PluginCommand::SetStatus { message } => {
597                self.handle_set_status(message);
598            }
599            PluginCommand::ApplyTheme { theme_name } => {
600                self.apply_theme(&theme_name);
601            }
602            PluginCommand::OverrideThemeColors { overrides } => {
603                self.handle_override_theme_colors(overrides);
604            }
605            PluginCommand::ReloadConfig => {
606                self.reload_config();
607            }
608            PluginCommand::SetSetting { path, value, .. } => {
609                self.handle_set_setting(path, value);
610            }
611            PluginCommand::AddPluginConfigField {
612                plugin_name,
613                field_name,
614                field_schema,
615            } => {
616                self.handle_add_plugin_config_field(plugin_name, field_name, field_schema);
617            }
618            PluginCommand::ReloadThemes { apply_theme } => {
619                self.reload_themes();
620                if let Some(theme_name) = apply_theme {
621                    self.apply_theme(&theme_name);
622                }
623            }
624            PluginCommand::RegisterGrammar {
625                language,
626                grammar_path,
627                extensions,
628            } => {
629                self.handle_register_grammar(language, grammar_path, extensions);
630            }
631            PluginCommand::RegisterLanguageConfig { language, config } => {
632                self.handle_register_language_config(language, config);
633            }
634            PluginCommand::RegisterLspServer { language, config } => {
635                self.handle_register_lsp_server(language, config);
636            }
637            PluginCommand::ReloadGrammars { callback_id } => {
638                self.handle_reload_grammars(callback_id);
639            }
640            PluginCommand::StartPrompt {
641                label,
642                prompt_type,
643                floating_overlay,
644            } => {
645                self.handle_start_prompt(label, prompt_type, floating_overlay);
646            }
647            PluginCommand::StartPromptWithInitial {
648                label,
649                prompt_type,
650                initial_value,
651                floating_overlay,
652            } => {
653                self.handle_start_prompt_with_initial(
654                    label,
655                    prompt_type,
656                    initial_value,
657                    floating_overlay,
658                );
659            }
660            PluginCommand::StartPromptAsync {
661                label,
662                initial_value,
663                callback_id,
664            } => {
665                self.handle_start_prompt_async(label, initial_value, callback_id);
666            }
667            PluginCommand::AwaitNextKey { callback_id } => {
668                self.handle_await_next_key(callback_id);
669            }
670            PluginCommand::SetKeyCaptureActive { active } => {
671                self.active_window_mut().key_capture_active = active;
672                if !active {
673                    // Capture window closed; any leftover queued keys
674                    // were intended for the plugin and should not now
675                    // leak into the editor's normal dispatch.
676                    self.active_window_mut().pending_key_capture_buffer.clear();
677                }
678            }
679            PluginCommand::SetPromptSuggestions { suggestions } => {
680                self.handle_set_prompt_suggestions(suggestions);
681            }
682            PluginCommand::SetPromptInputSync { sync } => {
683                if let Some(prompt) = &mut self.active_window_mut().prompt {
684                    prompt.sync_input_on_navigate = sync;
685                }
686            }
687            PluginCommand::SetPromptTitle { title } => {
688                if let Some(prompt) = &mut self.active_window_mut().prompt {
689                    prompt.title = title;
690                }
691            }
692            PluginCommand::SetPromptFooter { footer } => {
693                if let Some(prompt) = &mut self.active_window_mut().prompt {
694                    prompt.footer = footer;
695                }
696            }
697            PluginCommand::SetPromptToolbar { spec } => {
698                if let Some(prompt) = &mut self.active_window_mut().prompt {
699                    prompt.toolbar_widget = spec;
700                }
701            }
702            PluginCommand::ToggleOverlayToolbarWidget { key } => {
703                self.toggle_overlay_toolbar_widget(&key);
704            }
705            PluginCommand::SetPromptStatus { status } => {
706                if let Some(prompt) = &mut self.active_window_mut().prompt {
707                    prompt.status = status;
708                }
709            }
710            PluginCommand::SetPromptSelectedIndex { index } => {
711                if let Some(prompt) = &mut self.active_window_mut().prompt {
712                    let len = prompt.suggestions.len();
713                    if len > 0 {
714                        let clamped = (index as usize).min(len - 1);
715                        prompt.selected_suggestion = Some(clamped);
716                    }
717                }
718            }
719
720            // ==================== Session lifecycle ====================
721            // See docs/internal/orchestrator-sessions-design.md.
722            PluginCommand::CreateWindow { root, label } => {
723                if !root.is_absolute() {
724                    tracing::warn!(
725                        "CreateWindow rejected: root must be absolute, got {:?}",
726                        root
727                    );
728                } else {
729                    let _ = self.create_window_at(root, label);
730                }
731            }
732            PluginCommand::CreateWindowWithTerminal {
733                root,
734                label,
735                cwd,
736                command,
737                title,
738                request_id,
739            } => {
740                self.handle_create_window_with_terminal(
741                    root, label, cwd, command, title, request_id,
742                );
743            }
744            PluginCommand::SetActiveWindow { id } => {
745                self.set_active_window(id);
746            }
747            PluginCommand::CloseWindow { id } => {
748                let _ = self.close_window(id);
749            }
750            PluginCommand::PrewarmWindow { id } => {
751                self.prewarm_window(id);
752            }
753
754            // ==================== File watching ====================
755            PluginCommand::WatchPath {
756                path,
757                recursive,
758                request_id,
759            } => {
760                let result = if let Some(ref bridge) = self.async_bridge {
761                    self.file_watcher_manager.watch(bridge, &path, recursive)
762                } else {
763                    Err(
764                        "watchPath: no async bridge — file watching is unavailable in this build"
765                            .to_string(),
766                    )
767                };
768                self.last_watch_response_for_test = Some((request_id, result.clone()));
769                self.send_plugin_response(fresh_core::api::PluginResponse::WatchPathRegistered {
770                    request_id,
771                    result,
772                });
773            }
774            PluginCommand::UnwatchPath { handle } => {
775                self.file_watcher_manager.unwatch(handle);
776            }
777
778            PluginCommand::PreviewWindowInRect { id } => {
779                // Validate: only honour if the session exists and
780                // is not the active one (no point previewing the
781                // session whose UI is already on screen).
782                self.preview_window_id = match id {
783                    Some(sid) if sid != self.active_window && self.windows.contains_key(&sid) => {
784                        Some(sid)
785                    }
786                    _ => None,
787                };
788            }
789
790            // ==================== Command/Mode Registration ====================
791            PluginCommand::RegisterCommand { command } => {
792                self.handle_register_command(command);
793            }
794            PluginCommand::RegisterStatusBarElement {
795                plugin_name,
796                token_name,
797                title,
798            } => {
799                if let Err(e) = self.register_status_bar_element(&plugin_name, &token_name, &title)
800                {
801                    tracing::warn!("Failed to register statusbar element: {}", e);
802                }
803            }
804            PluginCommand::SetStatusBarValue {
805                buffer_id,
806                key,
807                value,
808            } => {
809                if let Err(e) =
810                    self.set_status_bar_value(fresh_core::BufferId(buffer_id as usize), &key, value)
811                {
812                    tracing::warn!("Failed to set statusbar value: {}", e);
813                }
814            }
815            PluginCommand::UnregisterCommand { name } => {
816                self.handle_unregister_command(name);
817            }
818            PluginCommand::DefineMode {
819                name,
820                bindings,
821                read_only,
822                allow_text_input,
823                inherit_normal_bindings,
824                plugin_name,
825            } => {
826                self.handle_define_mode(
827                    name,
828                    bindings,
829                    read_only,
830                    allow_text_input,
831                    inherit_normal_bindings,
832                    plugin_name,
833                );
834            }
835
836            // ==================== File/Navigation Commands ====================
837            PluginCommand::OpenFileInBackground { path, window_id } => {
838                let route_to_inactive = match window_id {
839                    Some(id) if id != self.active_window && self.windows.contains_key(&id) => {
840                        Some(id)
841                    }
842                    _ => None,
843                };
844                if let Some(target) = route_to_inactive {
845                    self.handle_open_file_in_inactive_session(target, path);
846                } else {
847                    self.handle_open_file_in_background(path);
848                }
849            }
850            PluginCommand::OpenFileAtLocation { path, line, column } => {
851                return self.handle_open_file_at_location(path, line, column);
852            }
853            PluginCommand::OpenFileInSplit {
854                split_id,
855                path,
856                line,
857                column,
858            } => {
859                return self.handle_open_file_in_split(split_id, path, line, column);
860            }
861            PluginCommand::ShowBuffer { buffer_id } => {
862                self.handle_show_buffer(buffer_id);
863            }
864            PluginCommand::CloseBuffer { buffer_id } => {
865                self.handle_close_buffer(buffer_id);
866            }
867            PluginCommand::CloseOtherBuffersInSplit {
868                buffer_id,
869                split_id,
870            } => {
871                self.handle_close_other_buffers_in_split(buffer_id, split_id);
872            }
873            PluginCommand::CloseAllBuffersInSplit { split_id } => {
874                self.handle_close_all_buffers_in_split(split_id);
875            }
876            PluginCommand::CloseBuffersToRightInSplit {
877                buffer_id,
878                split_id,
879            } => {
880                self.handle_close_buffers_to_right_in_split(buffer_id, split_id);
881            }
882            PluginCommand::CloseBuffersToLeftInSplit {
883                buffer_id,
884                split_id,
885            } => {
886                self.handle_close_buffers_to_left_in_split(buffer_id, split_id);
887            }
888
889            PluginCommand::MoveTabLeft => {
890                self.handle_move_tab_left();
891            }
892            PluginCommand::MoveTabRight => {
893                self.handle_move_tab_right();
894            }
895
896            // ==================== Animation Commands ====================
897            PluginCommand::StartAnimationArea { id, rect, kind } => {
898                self.handle_start_animation_area(id, rect, kind);
899            }
900            PluginCommand::StartAnimationVirtualBuffer {
901                id,
902                buffer_id,
903                kind,
904            } => {
905                self.handle_start_animation_virtual_buffer(id, buffer_id, kind);
906            }
907            PluginCommand::CancelAnimation { id } => {
908                self.active_window_mut()
909                    .animations
910                    .cancel(crate::view::animation::AnimationId::from_raw(id));
911            }
912
913            // ==================== LSP Commands ====================
914            PluginCommand::SendLspRequest {
915                language,
916                method,
917                params,
918                request_id,
919            } => {
920                self.handle_send_lsp_request(language, method, params, request_id);
921            }
922
923            // ==================== Clipboard Commands ====================
924            PluginCommand::SetClipboard { text } => {
925                self.handle_set_clipboard(text);
926            }
927
928            // ==================== Async Plugin Commands ====================
929            PluginCommand::SpawnProcess {
930                command,
931                args,
932                cwd,
933                stdout_to,
934                callback_id,
935            } => {
936                self.handle_spawn_process(command, args, cwd, stdout_to, callback_id);
937            }
938
939            PluginCommand::SpawnHostProcess {
940                command,
941                args,
942                cwd,
943                callback_id,
944            } => {
945                self.handle_spawn_host_process(command, args, cwd, callback_id);
946            }
947
948            PluginCommand::KillHostProcess { process_id } => {
949                self.handle_kill_host_process(process_id);
950            }
951
952            PluginCommand::SetAuthority { payload } => {
953                self.handle_set_authority(payload);
954            }
955
956            PluginCommand::ClearAuthority => {
957                tracing::info!("Plugin cleared authority; restoring local");
958                self.clear_authority();
959            }
960
961            PluginCommand::SetEnv { snippet, dir } => {
962                // Activation runs repo-controlled code, so it's only honored in
963                // a Trusted workspace — defense in depth even though the plugin
964                // already gates on `workspaceTrustLevel()`.
965                use crate::services::workspace_trust::TrustLevel;
966                if self.authority.workspace_trust.level() == TrustLevel::Trusted {
967                    self.authority
968                        .env_provider
969                        .set(snippet, dir.map(std::path::PathBuf::from));
970                    // Re-evaluate already-running tooling under the new env.
971                    self.request_restart(self.working_dir().to_path_buf());
972                } else {
973                    self.active_window_mut().status_message =
974                        Some("Workspace not trusted — cannot activate environment".to_string());
975                }
976            }
977
978            PluginCommand::ClearEnv => {
979                let was_active = self.authority.env_provider.is_active();
980                self.authority.env_provider.clear();
981                if was_active {
982                    self.request_restart(self.working_dir().to_path_buf());
983                }
984            }
985
986            PluginCommand::SetRemoteIndicatorState { state } => {
987                self.handle_set_remote_indicator_state(state);
988            }
989
990            PluginCommand::ClearRemoteIndicatorState => {
991                self.remote_indicator_override = None;
992            }
993
994            PluginCommand::SpawnProcessWait {
995                process_id,
996                callback_id,
997            } => {
998                self.handle_spawn_process_wait(process_id, callback_id);
999            }
1000
1001            PluginCommand::Delay {
1002                callback_id,
1003                duration_ms,
1004            } => {
1005                self.handle_delay(callback_id, duration_ms);
1006            }
1007
1008            PluginCommand::HttpFetch {
1009                url,
1010                target_path,
1011                callback_id,
1012            } => {
1013                self.handle_http_fetch(url, target_path, callback_id);
1014            }
1015
1016            PluginCommand::SpawnBackgroundProcess {
1017                process_id,
1018                command,
1019                args,
1020                cwd,
1021                callback_id,
1022            } => {
1023                self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
1024            }
1025
1026            PluginCommand::KillBackgroundProcess { process_id } => {
1027                self.handle_kill_background_process(process_id);
1028            }
1029
1030            // ==================== Virtual Buffer Commands (complex, kept inline) ====================
1031            PluginCommand::CreateVirtualBuffer {
1032                name,
1033                mode,
1034                read_only,
1035            } => {
1036                self.handle_create_virtual_buffer(name, mode, read_only);
1037            }
1038            PluginCommand::CreateVirtualBufferWithContent {
1039                name,
1040                mode,
1041                read_only,
1042                entries,
1043                show_line_numbers,
1044                show_cursors,
1045                editing_disabled,
1046                hidden_from_tabs,
1047                request_id,
1048            } => {
1049                self.handle_create_virtual_buffer_with_content(
1050                    name,
1051                    mode,
1052                    read_only,
1053                    entries,
1054                    show_line_numbers,
1055                    show_cursors,
1056                    editing_disabled,
1057                    hidden_from_tabs,
1058                    request_id,
1059                );
1060            }
1061            PluginCommand::CreateVirtualBufferInSplit {
1062                name,
1063                mode,
1064                read_only,
1065                entries,
1066                ratio,
1067                direction,
1068                panel_id,
1069                show_line_numbers,
1070                show_cursors,
1071                editing_disabled,
1072                line_wrap,
1073                before,
1074                role,
1075                request_id,
1076            } => {
1077                self.handle_create_virtual_buffer_in_split(
1078                    name,
1079                    mode,
1080                    read_only,
1081                    entries,
1082                    ratio,
1083                    direction,
1084                    panel_id,
1085                    show_line_numbers,
1086                    show_cursors,
1087                    editing_disabled,
1088                    line_wrap,
1089                    before,
1090                    role,
1091                    request_id,
1092                );
1093            }
1094            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1095                self.handle_set_virtual_buffer_content(buffer_id, entries);
1096            }
1097            PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1098                self.handle_get_text_properties_at_cursor(buffer_id);
1099            }
1100            PluginCommand::CreateVirtualBufferInExistingSplit {
1101                name,
1102                mode,
1103                read_only,
1104                entries,
1105                split_id,
1106                show_line_numbers,
1107                show_cursors,
1108                editing_disabled,
1109                line_wrap,
1110                request_id,
1111            } => {
1112                self.handle_create_virtual_buffer_in_existing_split(
1113                    name,
1114                    mode,
1115                    read_only,
1116                    entries,
1117                    split_id,
1118                    show_line_numbers,
1119                    show_cursors,
1120                    editing_disabled,
1121                    line_wrap,
1122                    request_id,
1123                );
1124            }
1125
1126            // ==================== Context Commands ====================
1127            PluginCommand::SetContext { name, active } => {
1128                self.handle_set_context(name, active);
1129            }
1130
1131            // ==================== Review Diff Commands ====================
1132            PluginCommand::SetReviewDiffHunks { hunks } => {
1133                self.active_window_mut().review_hunks = hunks;
1134                tracing::debug!(
1135                    "Set {} review hunks",
1136                    self.active_window_mut().review_hunks.len()
1137                );
1138            }
1139
1140            // ==================== Vi Mode Commands ====================
1141            PluginCommand::ExecuteAction { action_name } => {
1142                self.handle_execute_action(action_name);
1143            }
1144            PluginCommand::ExecuteActions { actions } => {
1145                self.handle_execute_actions(actions);
1146            }
1147            PluginCommand::GetBufferText {
1148                buffer_id,
1149                start,
1150                end,
1151                request_id,
1152            } => {
1153                self.handle_get_buffer_text(buffer_id, start, end, request_id);
1154            }
1155            PluginCommand::GetLineStartPosition {
1156                buffer_id,
1157                line,
1158                request_id,
1159            } => {
1160                self.handle_get_line_start_position(buffer_id, line, request_id);
1161            }
1162            PluginCommand::GetLineEndPosition {
1163                buffer_id,
1164                line,
1165                request_id,
1166            } => {
1167                self.handle_get_line_end_position(buffer_id, line, request_id);
1168            }
1169            PluginCommand::GetBufferLineCount {
1170                buffer_id,
1171                request_id,
1172            } => {
1173                self.handle_get_buffer_line_count(buffer_id, request_id);
1174            }
1175            PluginCommand::OpenFileStreaming { path, request_id } => {
1176                self.handle_open_file_streaming(path, request_id);
1177            }
1178            PluginCommand::RefreshBufferFromDisk {
1179                buffer_id,
1180                request_id,
1181            } => {
1182                self.handle_refresh_buffer_from_disk(buffer_id, request_id);
1183            }
1184            PluginCommand::SetBufferGroupPanelBuffer {
1185                group_id,
1186                panel_name,
1187                buffer_id,
1188                request_id,
1189            } => {
1190                self.handle_set_buffer_group_panel_buffer(
1191                    group_id, panel_name, buffer_id, request_id,
1192                );
1193            }
1194            PluginCommand::ScrollToLineCenter {
1195                split_id,
1196                buffer_id,
1197                line,
1198            } => {
1199                self.handle_scroll_to_line_center(split_id, buffer_id, line);
1200            }
1201            PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1202                self.handle_scroll_buffer_to_line(buffer_id, line);
1203            }
1204            PluginCommand::SetEditorMode { mode } => {
1205                self.handle_set_editor_mode(mode);
1206            }
1207
1208            // ==================== LSP Helper Commands ====================
1209            PluginCommand::ShowActionPopup {
1210                popup_id,
1211                title,
1212                message,
1213                actions,
1214            } => {
1215                self.handle_show_action_popup(popup_id, title, message, actions);
1216            }
1217
1218            PluginCommand::SetLspMenuContributions {
1219                plugin_id,
1220                language,
1221                items,
1222            } => {
1223                self.handle_set_lsp_menu_contributions(plugin_id, language, items);
1224            }
1225
1226            PluginCommand::DisableLspForLanguage { language } => {
1227                self.handle_disable_lsp_for_language(language);
1228            }
1229
1230            PluginCommand::RestartLspForLanguage { language } => {
1231                self.handle_restart_lsp_for_language(language);
1232            }
1233
1234            PluginCommand::SetLspRootUri { language, uri } => {
1235                self.handle_set_lsp_root_uri(language, uri);
1236            }
1237
1238            // ==================== Scroll Sync Commands ====================
1239            PluginCommand::CreateScrollSyncGroup {
1240                group_id,
1241                left_split,
1242                right_split,
1243            } => {
1244                self.handle_create_scroll_sync_group(group_id, left_split, right_split);
1245            }
1246            PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1247                self.handle_set_scroll_sync_anchors(group_id, anchors);
1248            }
1249            PluginCommand::RemoveScrollSyncGroup { group_id } => {
1250                self.handle_remove_scroll_sync_group(group_id);
1251            }
1252
1253            // ==================== Composite Buffer Commands ====================
1254            PluginCommand::CreateCompositeBuffer {
1255                name,
1256                mode,
1257                layout,
1258                sources,
1259                hunks,
1260                initial_focus_hunk,
1261                request_id,
1262            } => {
1263                self.handle_create_composite_buffer(
1264                    name,
1265                    mode,
1266                    layout,
1267                    sources,
1268                    hunks,
1269                    initial_focus_hunk,
1270                    request_id,
1271                );
1272            }
1273            PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1274                self.handle_update_composite_alignment(buffer_id, hunks);
1275            }
1276            PluginCommand::CloseCompositeBuffer { buffer_id } => {
1277                self.active_window_mut().close_composite_buffer(buffer_id);
1278            }
1279            PluginCommand::FlushLayout => {
1280                self.flush_layout();
1281            }
1282            PluginCommand::CompositeNextHunk { buffer_id } => {
1283                let split_id = self
1284                    .windows
1285                    .get(&self.active_window)
1286                    .and_then(|w| w.buffers.splits())
1287                    .map(|(mgr, _)| mgr)
1288                    .expect("active window must have a populated split layout")
1289                    .active_split();
1290                self.active_window_mut()
1291                    .composite_next_hunk(split_id, buffer_id);
1292            }
1293            PluginCommand::CompositePrevHunk { buffer_id } => {
1294                let split_id = self
1295                    .windows
1296                    .get(&self.active_window)
1297                    .and_then(|w| w.buffers.splits())
1298                    .map(|(mgr, _)| mgr)
1299                    .expect("active window must have a populated split layout")
1300                    .active_split();
1301                self.active_window_mut()
1302                    .composite_prev_hunk(split_id, buffer_id);
1303            }
1304
1305            // ==================== Buffer Groups ====================
1306            PluginCommand::CreateBufferGroup {
1307                name,
1308                mode,
1309                layout_json,
1310                request_id,
1311            } => {
1312                self.handle_create_buffer_group(name, mode, layout_json, request_id);
1313            }
1314            PluginCommand::SetPanelContent {
1315                group_id,
1316                panel_name,
1317                entries,
1318            } => {
1319                self.set_panel_content(group_id, panel_name, entries);
1320            }
1321            PluginCommand::CloseBufferGroup { group_id } => {
1322                self.close_buffer_group(group_id);
1323            }
1324            PluginCommand::FocusPanel {
1325                group_id,
1326                panel_name,
1327            } => {
1328                self.focus_panel(group_id, panel_name);
1329            }
1330
1331            // ==================== File Operations ====================
1332            PluginCommand::SaveBufferToPath { buffer_id, path } => {
1333                self.handle_save_buffer_to_path(buffer_id, path);
1334            }
1335
1336            // ==================== Plugin Management ====================
1337            #[cfg(feature = "plugins")]
1338            PluginCommand::LoadPlugin { path, callback_id } => {
1339                self.handle_load_plugin(path, callback_id);
1340            }
1341            #[cfg(feature = "plugins")]
1342            PluginCommand::UnloadPlugin { name, callback_id } => {
1343                self.handle_unload_plugin(name, callback_id);
1344            }
1345            #[cfg(feature = "plugins")]
1346            PluginCommand::ReloadPlugin { name, callback_id } => {
1347                self.handle_reload_plugin(name, callback_id);
1348            }
1349            #[cfg(feature = "plugins")]
1350            PluginCommand::ListPlugins { callback_id } => {
1351                self.handle_list_plugins(callback_id);
1352            }
1353            // When plugins feature is disabled, these commands are no-ops
1354            #[cfg(not(feature = "plugins"))]
1355            PluginCommand::LoadPlugin { .. }
1356            | PluginCommand::UnloadPlugin { .. }
1357            | PluginCommand::ReloadPlugin { .. }
1358            | PluginCommand::ListPlugins { .. } => {
1359                tracing::warn!("Plugin management commands require the 'plugins' feature");
1360            }
1361
1362            // ==================== Terminal Commands ====================
1363            PluginCommand::CreateTerminal {
1364                cwd,
1365                direction,
1366                ratio,
1367                focus,
1368                persistent,
1369                window_id,
1370                command,
1371                title,
1372                request_id,
1373            } => {
1374                self.handle_create_terminal(
1375                    cwd, direction, ratio, focus, persistent, window_id, command, title, request_id,
1376                );
1377            }
1378
1379            PluginCommand::SendTerminalInput { terminal_id, data } => {
1380                self.handle_send_terminal_input(terminal_id, data);
1381            }
1382
1383            PluginCommand::CloseTerminal { terminal_id } => {
1384                self.handle_close_terminal(terminal_id);
1385            }
1386
1387            PluginCommand::SignalWindow { id, signal } => {
1388                self.handle_signal_window(id, &signal);
1389            }
1390
1391            PluginCommand::GrepProject {
1392                pattern,
1393                fixed_string,
1394                case_sensitive,
1395                max_results,
1396                whole_words,
1397                callback_id,
1398            } => {
1399                self.handle_grep_project(
1400                    pattern,
1401                    fixed_string,
1402                    case_sensitive,
1403                    max_results,
1404                    whole_words,
1405                    callback_id,
1406                );
1407            }
1408
1409            PluginCommand::BeginSearch {
1410                pattern,
1411                fixed_string,
1412                case_sensitive,
1413                max_results,
1414                whole_words,
1415                handle_id,
1416            } => {
1417                self.handle_begin_search(
1418                    pattern,
1419                    fixed_string,
1420                    case_sensitive,
1421                    max_results,
1422                    whole_words,
1423                    handle_id,
1424                );
1425            }
1426
1427            PluginCommand::ReplaceInBuffer {
1428                file_path,
1429                matches,
1430                replacement,
1431                callback_id,
1432            } => {
1433                self.handle_replace_in_buffer(file_path, matches, replacement, callback_id);
1434            }
1435
1436            PluginCommand::MountWidgetPanel {
1437                panel_id,
1438                buffer_id,
1439                spec,
1440            } => {
1441                self.handle_mount_widget_panel(panel_id, buffer_id, spec);
1442            }
1443
1444            PluginCommand::UpdateWidgetPanel { panel_id, spec } => {
1445                self.handle_update_widget_panel(panel_id, spec);
1446            }
1447
1448            PluginCommand::UnmountWidgetPanel { panel_id } => {
1449                self.handle_unmount_widget_panel(panel_id);
1450            }
1451
1452            PluginCommand::WidgetCommand { panel_id, action } => {
1453                self.handle_widget_command(panel_id, action);
1454            }
1455
1456            PluginCommand::WidgetMutate { panel_id, mutation } => {
1457                self.handle_widget_mutate(panel_id, mutation);
1458            }
1459
1460            PluginCommand::MountFloatingWidget {
1461                panel_id,
1462                spec,
1463                width_pct,
1464                height_pct,
1465            } => {
1466                self.handle_mount_floating_widget(panel_id, spec, width_pct, height_pct);
1467            }
1468
1469            PluginCommand::UpdateFloatingWidget { panel_id, spec } => {
1470                self.handle_update_floating_widget(panel_id, spec);
1471            }
1472
1473            PluginCommand::UnmountFloatingWidget { panel_id } => {
1474                self.handle_unmount_floating_widget(panel_id);
1475            }
1476        }
1477        Ok(())
1478    }
1479
1480    /// Save a buffer to a specific file path (for :w filename)
1481    fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1482        if let Some(state) = self
1483            .windows
1484            .get_mut(&self.active_window)
1485            .map(|w| &mut w.buffers)
1486            .expect("active window present")
1487            .get_mut(&buffer_id)
1488        {
1489            // Save to the specified path
1490            match state.buffer.save_to_file(&path) {
1491                Ok(()) => {
1492                    // save_to_file already updates file_path internally via finalize_save
1493                    // Run on-save actions (formatting, etc.)
1494                    if let Err(e) = self.finalize_save(Some(path)) {
1495                        tracing::warn!("Failed to finalize save: {}", e);
1496                    }
1497                    tracing::debug!("Saved buffer {:?} to path", buffer_id);
1498                }
1499                Err(e) => {
1500                    self.handle_set_status(format!("Error saving: {}", e));
1501                    tracing::error!("Failed to save buffer to path: {}", e);
1502                }
1503            }
1504        } else {
1505            self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1506            tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1507        }
1508    }
1509
1510    /// Load a plugin from a file path
1511    #[cfg(feature = "plugins")]
1512    fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1513        let load_result = self.plugin_manager.read().unwrap().load_plugin(&path);
1514        match load_result {
1515            Ok(()) => {
1516                tracing::info!("Loaded plugin from {:?}", path);
1517                self.plugin_manager
1518                    .read()
1519                    .unwrap()
1520                    .resolve_callback(callback_id, "true".to_string());
1521            }
1522            Err(e) => {
1523                tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1524                self.plugin_manager
1525                    .read()
1526                    .unwrap()
1527                    .reject_callback(callback_id, format!("{}", e));
1528            }
1529        }
1530    }
1531
1532    /// Unload a plugin by name
1533    #[cfg(feature = "plugins")]
1534    fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1535        // Drop the write guard before the read lock below (match-scrutinee
1536        // temporaries would otherwise live until end-of-match).
1537        let result = self.plugin_manager.write().unwrap().unload_plugin(&name);
1538        match result {
1539            Ok(()) => {
1540                tracing::info!("Unloaded plugin: {}", name);
1541                if let Ok(mut schemas) = self.plugin_schemas.write() {
1542                    schemas.remove(&name);
1543                }
1544                self.plugin_manager
1545                    .read()
1546                    .unwrap()
1547                    .resolve_callback(callback_id, "true".to_string());
1548            }
1549            Err(e) => {
1550                tracing::error!("Failed to unload plugin '{}': {}", name, e);
1551                self.plugin_manager
1552                    .read()
1553                    .unwrap()
1554                    .reject_callback(callback_id, format!("{}", e));
1555            }
1556        }
1557    }
1558
1559    /// Reload a plugin by name
1560    #[cfg(feature = "plugins")]
1561    fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1562        // Capture the plugin's path before reloading so we can refresh its
1563        // schema sidecar too. `list_plugins` is cheap (one channel
1564        // round-trip).
1565        let path = self
1566            .plugin_manager
1567            .read()
1568            .unwrap()
1569            .list_plugins()
1570            .into_iter()
1571            .find(|p| p.name == name)
1572            .map(|p| p.path);
1573        let _ = path; // schema is now re-registered by plugin code on reload
1574        let reload_result = self.plugin_manager.read().unwrap().reload_plugin(&name);
1575        match reload_result {
1576            Ok(()) => {
1577                tracing::info!("Reloaded plugin: {}", name);
1578                self.plugin_manager
1579                    .read()
1580                    .unwrap()
1581                    .resolve_callback(callback_id, "true".to_string());
1582            }
1583            Err(e) => {
1584                tracing::error!("Failed to reload plugin '{}': {}", name, e);
1585                self.plugin_manager
1586                    .read()
1587                    .unwrap()
1588                    .reject_callback(callback_id, format!("{}", e));
1589            }
1590        }
1591    }
1592
1593    /// List all loaded plugins
1594    #[cfg(feature = "plugins")]
1595    fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
1596        let plugins = self.plugin_manager.read().unwrap().list_plugins();
1597        // Serialize to JSON array of { name, path, enabled }
1598        let json_array: Vec<serde_json::Value> = plugins
1599            .iter()
1600            .map(|p| {
1601                serde_json::json!({
1602                    "name": p.name,
1603                    "path": p.path.to_string_lossy(),
1604                    "enabled": p.enabled
1605                })
1606            })
1607            .collect();
1608        let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
1609        self.plugin_manager
1610            .read()
1611            .unwrap()
1612            .resolve_callback(callback_id, json_str);
1613    }
1614
1615    /// Execute an editor action by name (for vi mode plugin)
1616    fn handle_execute_action(&mut self, action_name: String) {
1617        use crate::input::keybindings::Action;
1618        use std::collections::HashMap;
1619
1620        // Parse the action name into an Action enum
1621        if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
1622            // Execute the action
1623            if let Err(e) = self.handle_action(action) {
1624                tracing::warn!("Failed to execute action '{}': {}", action_name, e);
1625            } else {
1626                tracing::debug!("Executed action: {}", action_name);
1627            }
1628        } else {
1629            tracing::warn!("Unknown action: {}", action_name);
1630        }
1631    }
1632
1633    /// Execute multiple actions in sequence, each with an optional repeat count
1634    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1635    fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
1636        use crate::input::keybindings::Action;
1637        use std::collections::HashMap;
1638
1639        for action_spec in actions {
1640            if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
1641                // Execute the action `count` times
1642                for _ in 0..action_spec.count {
1643                    if let Err(e) = self.handle_action(action.clone()) {
1644                        tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
1645                        return; // Stop on first error
1646                    }
1647                }
1648                tracing::debug!(
1649                    "Executed action '{}' {} time(s)",
1650                    action_spec.action,
1651                    action_spec.count
1652                );
1653            } else {
1654                tracing::warn!("Unknown action: {}", action_spec.action);
1655                return; // Stop on unknown action
1656            }
1657        }
1658    }
1659
1660    /// Get text from a buffer range (for vi mode yank operations)
1661    fn handle_get_buffer_text(
1662        &mut self,
1663        buffer_id: BufferId,
1664        start: usize,
1665        end: usize,
1666        request_id: u64,
1667    ) {
1668        let result = if let Some(state) = self
1669            .windows
1670            .get_mut(&self.active_window)
1671            .map(|w| &mut w.buffers)
1672            .expect("active window present")
1673            .get_mut(&buffer_id)
1674        {
1675            // Get text from the buffer using the mutable get_text_range method
1676            let len = state.buffer.len();
1677            if start <= end && end <= len {
1678                Ok(state.get_text_range(start, end))
1679            } else {
1680                Err(format!(
1681                    "Invalid range {}..{} for buffer of length {}",
1682                    start, end, len
1683                ))
1684            }
1685        } else {
1686            Err(format!("Buffer {:?} not found", buffer_id))
1687        };
1688
1689        // Resolve the JavaScript Promise callback directly
1690        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1691        match result {
1692            Ok(text) => {
1693                // Serialize text as JSON string
1694                let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
1695                self.plugin_manager
1696                    .read()
1697                    .unwrap()
1698                    .resolve_callback(callback_id, json);
1699            }
1700            Err(error) => {
1701                self.plugin_manager
1702                    .read()
1703                    .unwrap()
1704                    .reject_callback(callback_id, error);
1705            }
1706        }
1707    }
1708
1709    /// Set the global editor mode (for vi mode)
1710    fn handle_set_editor_mode(&mut self, mode: Option<String>) {
1711        self.active_window_mut().editor_mode = mode.clone();
1712        tracing::debug!("Set editor mode: {:?}", mode);
1713    }
1714
1715    /// Normalize a plugin-supplied `BufferId`: treat id 0 as "use the active buffer".
1716    fn resolve_buffer_id(&self, buffer_id: BufferId) -> BufferId {
1717        if buffer_id.0 == 0 {
1718            self.active_buffer()
1719        } else {
1720            buffer_id
1721        }
1722    }
1723
1724    /// Serialize `value` as JSON and resolve `request_id` as a JS Promise callback.
1725    fn resolve_json_callback<T: serde::Serialize>(&mut self, request_id: u64, value: T) {
1726        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1727        let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1728        self.plugin_manager
1729            .read()
1730            .unwrap()
1731            .resolve_callback(callback_id, json);
1732    }
1733
1734    /// Get the byte offset of the start of a line in the active buffer
1735    fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1736        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1737        let result = self
1738            .windows
1739            .get_mut(&self.active_window)
1740            .map(|w| &mut w.buffers)
1741            .expect("active window present")
1742            .get_mut(&actual_buffer_id)
1743            .and_then(|state| {
1744                let len = state.buffer.len();
1745                let content = state.get_text_range(0, len);
1746                buffer_line_byte_offset(&content, len, line as usize, false)
1747            });
1748        self.resolve_json_callback(request_id, result);
1749    }
1750
1751    /// Get the byte offset of the end of a line (position of its terminating newline,
1752    /// or `buffer_len` for the last line without a trailing newline).
1753    fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1754        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1755        let result = self
1756            .windows
1757            .get_mut(&self.active_window)
1758            .map(|w| &mut w.buffers)
1759            .expect("active window present")
1760            .get_mut(&actual_buffer_id)
1761            .and_then(|state| {
1762                let len = state.buffer.len();
1763                let content = state.get_text_range(0, len);
1764                buffer_line_byte_offset(&content, len, line as usize, true)
1765            });
1766        self.resolve_json_callback(request_id, result);
1767    }
1768
1769    /// Get the total number of lines in a buffer
1770    fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
1771        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1772
1773        let result = if let Some(state) = self
1774            .windows
1775            .get_mut(&self.active_window)
1776            .map(|w| &mut w.buffers)
1777            .expect("active window present")
1778            .get_mut(&actual_buffer_id)
1779        {
1780            let buffer_len = state.buffer.len();
1781            let content = state.get_text_range(0, buffer_len);
1782
1783            // Count lines (number of newlines + 1, unless empty)
1784            if content.is_empty() {
1785                Some(1) // Empty buffer has 1 line
1786            } else {
1787                let newline_count = content.chars().filter(|&c| c == '\n').count();
1788                // If file ends with newline, don't count extra line
1789                let ends_with_newline = content.ends_with('\n');
1790                if ends_with_newline {
1791                    Some(newline_count)
1792                } else {
1793                    Some(newline_count + 1)
1794                }
1795            }
1796        } else {
1797            None
1798        };
1799
1800        self.resolve_json_callback(request_id, result);
1801    }
1802
1803    /// Open `path` as a regular buffer for plugin-driven streaming
1804    /// display. The file is created (empty) if missing.
1805    ///
1806    /// Routes through the same `open_file_no_focus` orchestrator that
1807    /// `editor.openFile` uses, so the buffer gets the full setup
1808    /// (encoding/binary detection, language detection, buffer settings,
1809    /// margin config, per-split BufferViewState defaults). This is
1810    /// critical for things like the scrollbar's visual-row index —
1811    /// bypassing this setup and going straight to `BufferData::Unloaded`
1812    /// breaks `line_count()` and any code that depends on it.
1813    ///
1814    /// Designed for buffers that will be filled by a concurrent
1815    /// `spawnProcess` with `stdoutTo`. Pair with `RefreshBufferFromDisk`
1816    /// to grow the buffer as the file is written; `extend_streaming`
1817    /// (called by that path) counts newlines in the appended region
1818    /// so the buffer's line index stays correct as it grows.
1819    fn handle_open_file_streaming(&mut self, path: std::path::PathBuf, request_id: u64) {
1820        // Ensure the file exists at 0 bytes if missing, so the open
1821        // path has something to load.
1822        if !self.authority.filesystem.exists(&path) {
1823            if let Some(parent) = path.parent() {
1824                if !parent.as_os_str().is_empty() {
1825                    if let Err(e) = std::fs::create_dir_all(parent) {
1826                        tracing::warn!(
1827                            "openFileStreaming: failed to create parent dir {:?}: {}",
1828                            parent,
1829                            e
1830                        );
1831                        self.resolve_json_callback::<Option<u64>>(request_id, None);
1832                        return;
1833                    }
1834                }
1835            }
1836            if let Err(e) = std::fs::write(&path, b"") {
1837                tracing::warn!(
1838                    "openFileStreaming: failed to create empty file at {:?}: {}",
1839                    path,
1840                    e
1841                );
1842                self.resolve_json_callback::<Option<u64>>(request_id, None);
1843                return;
1844            }
1845        }
1846
1847        // Use the same orchestrator that backs `editor.openFile`. This
1848        // ensures the buffer is set up identically to a user-opened
1849        // file (settings, language, view-state defaults, line indexing).
1850        let buffer_id = match self.open_file_no_focus(&path) {
1851            Ok(id) => id,
1852            Err(e) => {
1853                tracing::warn!(
1854                    "openFileStreaming: open_file_no_focus failed for {:?}: {}",
1855                    path,
1856                    e
1857                );
1858                self.resolve_json_callback::<Option<u64>>(request_id, None);
1859                return;
1860            }
1861        };
1862
1863        // Plugin-managed surfaces (typically buffer-group panel
1864        // targets) shouldn't show up in quick-switch / tab strip, and
1865        // shouldn't be auto-reverted on file change — the plugin is
1866        // driving the file's contents itself via `extend_streaming`.
1867        if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
1868            meta.hidden_from_tabs = true;
1869            meta.auto_revert_enabled = false;
1870        }
1871        let active_split = self
1872            .windows
1873            .get(&self.active_window)
1874            .and_then(|w| w.buffers.splits())
1875            .map(|(mgr, _)| mgr)
1876            .expect("active window must have a populated split layout")
1877            .active_split();
1878        if let Some(vs) = self
1879            .windows
1880            .get_mut(&self.active_window)
1881            .and_then(|w| w.split_view_states_mut())
1882            .expect("active window must have a populated split layout")
1883            .get_mut(&active_split)
1884        {
1885            use crate::view::split::TabTarget;
1886            vs.open_buffers
1887                .retain(|t| !matches!(t, TabTarget::Buffer(b) if *b == buffer_id));
1888        }
1889
1890        self.resolve_json_callback(request_id, Some(buffer_id.0));
1891    }
1892
1893    /// Re-point a buffer-group's panel at a different buffer id.
1894    /// Delegates to `BufferGroupOps::set_buffer_group_panel_buffer`.
1895    fn handle_set_buffer_group_panel_buffer(
1896        &mut self,
1897        group_id: usize,
1898        panel_name: String,
1899        buffer_id: BufferId,
1900        request_id: u64,
1901    ) {
1902        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1903        let ok = self.set_buffer_group_panel_buffer(group_id, panel_name, actual_buffer_id);
1904        self.resolve_json_callback(request_id, ok);
1905    }
1906
1907    /// Re-stat the file backing `buffer_id` and extend the buffer if
1908    /// the file has grown. No-op if the buffer has no file path or the
1909    /// file didn't grow. Resolves with the new total byte length.
1910    fn handle_refresh_buffer_from_disk(&mut self, buffer_id: BufferId, request_id: u64) {
1911        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1912
1913        let path = self
1914            .windows
1915            .get(&self.active_window)
1916            .and_then(|w| w.buffers.splits())
1917            .map(|(_, _)| ())
1918            .and_then(|_| {
1919                self.windows
1920                    .get(&self.active_window)?
1921                    .buffers
1922                    .get(&actual_buffer_id)?
1923                    .buffer
1924                    .file_path()
1925                    .map(|p| p.to_path_buf())
1926            });
1927
1928        let Some(path) = path else {
1929            // No file path — nothing to refresh.
1930            self.resolve_json_callback::<Option<usize>>(request_id, None);
1931            return;
1932        };
1933
1934        let new_size = match self.authority.filesystem.metadata(&path) {
1935            Ok(m) => m.size as usize,
1936            Err(_) => {
1937                self.resolve_json_callback::<Option<usize>>(request_id, None);
1938                return;
1939            }
1940        };
1941
1942        let new_total = if let Some(state) = self
1943            .windows
1944            .get_mut(&self.active_window)
1945            .map(|w| &mut w.buffers)
1946            .expect("active window present")
1947            .get_mut(&actual_buffer_id)
1948        {
1949            let old = state.buffer.total_bytes();
1950            if new_size > old {
1951                state.buffer.extend_streaming(&path, new_size);
1952            }
1953            state.buffer.total_bytes()
1954        } else {
1955            self.resolve_json_callback::<Option<usize>>(request_id, None);
1956            return;
1957        };
1958
1959        self.resolve_json_callback(request_id, Some(new_total));
1960    }
1961
1962    /// Scroll a split to center a specific line in the viewport
1963    fn handle_scroll_to_line_center(
1964        &mut self,
1965        split_id: SplitId,
1966        buffer_id: BufferId,
1967        line: usize,
1968    ) {
1969        let actual_split_id = if split_id.0 == 0 {
1970            self.windows
1971                .get(&self.active_window)
1972                .and_then(|w| w.buffers.splits())
1973                .map(|(mgr, _)| mgr)
1974                .expect("active window must have a populated split layout")
1975                .active_split()
1976        } else {
1977            LeafId(split_id)
1978        };
1979        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1980
1981        // Get viewport height
1982        let viewport_height = if let Some(view_state) = self
1983            .windows
1984            .get(&self.active_window)
1985            .and_then(|w| w.buffers.splits())
1986            .map(|(_, vs)| vs)
1987            .expect("active window must have a populated split layout")
1988            .get(&actual_split_id)
1989        {
1990            view_state.viewport.height as usize
1991        } else {
1992            return;
1993        };
1994
1995        // Calculate the target line to scroll to (center the requested line)
1996        let lines_above = viewport_height / 2;
1997        let target_line = line.saturating_sub(lines_above);
1998
1999        self.active_window_mut().scroll_split_viewport_to(
2000            actual_buffer_id,
2001            actual_split_id,
2002            target_line,
2003            true,
2004        );
2005    }
2006
2007    /// Scroll every split whose active buffer is `buffer_id` so that
2008    /// `line` is within the viewport. Used by plugin panels (buffer
2009    /// groups) whose plugin-side "selected row" doesn't drive the
2010    /// buffer cursor — after updating the selection, the plugin calls
2011    /// this to bring the selected row into view.
2012    ///
2013    /// Walks both the main split tree's leaves AND the inner leaves of
2014    /// all Grouped subtrees stored in `grouped_subtrees`, because the
2015    /// latter are not represented in `split_manager`'s tree.
2016    fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
2017        if !self
2018            .windows
2019            .get(&self.active_window)
2020            .map(|w| &w.buffers)
2021            .expect("active window present")
2022            .contains_key(&buffer_id)
2023        {
2024            return;
2025        }
2026
2027        // Collect the leaf ids whose active buffer is `buffer_id`.
2028        let mut target_leaves: Vec<LeafId> = Vec::new();
2029
2030        // Main tree: walk its leaves.
2031        for leaf_id in self
2032            .windows
2033            .get(&self.active_window)
2034            .and_then(|w| w.buffers.splits())
2035            .map(|(mgr, _)| mgr)
2036            .expect("active window must have a populated split layout")
2037            .root()
2038            .leaf_split_ids()
2039        {
2040            if let Some(vs) = self
2041                .windows
2042                .get(&self.active_window)
2043                .and_then(|w| w.buffers.splits())
2044                .map(|(_, vs)| vs)
2045                .expect("active window must have a populated split layout")
2046                .get(&leaf_id)
2047            {
2048                if vs.active_buffer == buffer_id {
2049                    target_leaves.push(leaf_id);
2050                }
2051            }
2052        }
2053
2054        // Grouped subtrees: walk each group's inner leaves.
2055        for (_group_leaf_id, node) in self.active_window().grouped_subtrees.iter() {
2056            if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
2057                for inner_leaf in layout.leaf_split_ids() {
2058                    if let Some(vs) = self
2059                        .windows
2060                        .get(&self.active_window)
2061                        .and_then(|w| w.buffers.splits())
2062                        .map(|(_, vs)| vs)
2063                        .expect("active window must have a populated split layout")
2064                        .get(&inner_leaf)
2065                    {
2066                        if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
2067                            target_leaves.push(inner_leaf);
2068                        }
2069                    }
2070                }
2071            }
2072        }
2073
2074        if target_leaves.is_empty() {
2075            return;
2076        }
2077
2078        self.active_window_mut()
2079            .scroll_buffer_to_line_in_splits(buffer_id, &target_leaves, line);
2080    }
2081
2082    fn handle_spawn_host_process(
2083        &mut self,
2084        command: String,
2085        args: Vec<String>,
2086        cwd: Option<String>,
2087        callback_id: JsCallbackId,
2088    ) {
2089        // Bypass the active authority on purpose: this is
2090        // reserved for plugin internals that must run host-side
2091        // work (e.g. `devcontainer up`) before the authority
2092        // they want is even built. Uses the same callback shape
2093        // as `SpawnProcess` so the plugin-facing API is
2094        // symmetric.
2095        //
2096        // Kill handle: we store a oneshot sender in
2097        // `host_process_handles` keyed by the callback id. A
2098        // `KillHostProcess` dispatch sends on it; the spawn
2099        // task's `tokio::select!` then start_kill()s the
2100        // child. This lets a plugin cancel a long-running
2101        // spawn (e.g. "Cancel Startup" on the Remote
2102        // Indicator popup during `devcontainer up`).
2103        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2104            use tokio::io::{AsyncReadExt, BufReader};
2105            use tokio::process::Command as TokioCommand;
2106
2107            let effective_cwd = cwd.or_else(|| {
2108                std::env::current_dir()
2109                    .map(|p| p.to_string_lossy().to_string())
2110                    .ok()
2111            });
2112            let sender = bridge.sender();
2113            let process_id = callback_id.as_u64();
2114
2115            // Workspace Trust gates host spawns too. `spawnHostProcess`
2116            // deliberately bypasses the authority spawner, so the choke-point
2117            // guard never sees it — enforce the level here directly. Blocked
2118            // fails every host spawn; Restricted refuses repo-local
2119            // executables. Without this, Blocked wouldn't actually block
2120            // everything.
2121            if let crate::services::workspace_trust::SpawnDecision::Deny(reason) = self
2122                .authority
2123                .workspace_trust
2124                .decide(&command, effective_cwd.as_deref())
2125            {
2126                #[allow(clippy::let_underscore_must_use)]
2127                let _ = sender.send(AsyncMessage::PluginProcessOutput {
2128                    process_id,
2129                    stdout: String::new(),
2130                    stderr: reason,
2131                    exit_code: -1,
2132                });
2133                return;
2134            }
2135
2136            let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
2137            self.host_process_handles.insert(process_id, kill_tx);
2138
2139            runtime.spawn(async move {
2140                use crate::services::process_hidden::HideWindow;
2141                let mut cmd = TokioCommand::new(&command);
2142                cmd.args(&args);
2143                cmd.stdout(std::process::Stdio::piped());
2144                cmd.stderr(std::process::Stdio::piped());
2145                cmd.hide_window();
2146                if let Some(ref dir) = effective_cwd {
2147                    cmd.current_dir(dir);
2148                }
2149                let mut child = match cmd.spawn() {
2150                    Ok(c) => c,
2151                    Err(e) => {
2152                        #[allow(clippy::let_underscore_must_use)]
2153                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
2154                            process_id,
2155                            stdout: String::new(),
2156                            stderr: e.to_string(),
2157                            exit_code: -1,
2158                        });
2159                        return;
2160                    }
2161                };
2162
2163                // Take the pipes out of the Child so the
2164                // reader tasks own them; then `child.wait()`
2165                // has exclusive mutable access for the
2166                // kill-or-exit select. Matches the
2167                // fresh-plugin-runtime process.rs pattern.
2168                let stdout_pipe = child.stdout.take();
2169                let stderr_pipe = child.stderr.take();
2170
2171                let stdout_fut = async {
2172                    let mut buf = String::new();
2173                    if let Some(s) = stdout_pipe {
2174                        #[allow(clippy::let_underscore_must_use)]
2175                        let _ = BufReader::new(s).read_to_string(&mut buf).await;
2176                    }
2177                    buf
2178                };
2179                let stderr_fut = async {
2180                    let mut buf = String::new();
2181                    if let Some(s) = stderr_pipe {
2182                        #[allow(clippy::let_underscore_must_use)]
2183                        let _ = BufReader::new(s).read_to_string(&mut buf).await;
2184                    }
2185                    buf
2186                };
2187                let wait_fut = async {
2188                    tokio::select! {
2189                        status = child.wait() => {
2190                            status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
2191                        }
2192                        _ = &mut kill_rx => {
2193                            // Best-effort SIGKILL + reap.
2194                            // Children of the killed
2195                            // process may leak (Q-C2).
2196                            #[allow(clippy::let_underscore_must_use)]
2197                            let _ = child.start_kill();
2198                            child
2199                                .wait()
2200                                .await
2201                                .map(|s| s.code().unwrap_or(-1))
2202                                .unwrap_or(-1)
2203                        }
2204                    }
2205                };
2206                let (stdout, stderr, exit_code) = tokio::join!(stdout_fut, stderr_fut, wait_fut);
2207
2208                #[allow(clippy::let_underscore_must_use)]
2209                let _ = sender.send(AsyncMessage::PluginProcessOutput {
2210                    process_id,
2211                    stdout,
2212                    stderr,
2213                    exit_code,
2214                });
2215            });
2216        } else {
2217            self.plugin_manager
2218                .read()
2219                .unwrap()
2220                .reject_callback(callback_id, "Async runtime not available".to_string());
2221        }
2222    }
2223
2224    fn handle_spawn_background_process(
2225        &mut self,
2226        process_id: u64,
2227        command: String,
2228        args: Vec<String>,
2229        cwd: Option<String>,
2230        callback_id: JsCallbackId,
2231    ) {
2232        // Spawn background process with streaming output via tokio
2233        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2234            use tokio::io::{AsyncBufReadExt, BufReader};
2235            use tokio::process::Command as TokioCommand;
2236
2237            let effective_cwd = cwd.unwrap_or_else(|| {
2238                std::env::current_dir()
2239                    .map(|p| p.to_string_lossy().to_string())
2240                    .unwrap_or_else(|_| ".".to_string())
2241            });
2242
2243            let sender = bridge.sender();
2244            let sender_stdout = sender.clone();
2245            let sender_stderr = sender.clone();
2246            let callback_id_u64 = callback_id.as_u64();
2247
2248            // Receiver may be dropped if editor is shutting down
2249            #[allow(clippy::let_underscore_must_use)]
2250            let handle = runtime.spawn(async move {
2251                use crate::services::process_hidden::HideWindow;
2252                let mut child = match TokioCommand::new(&command)
2253                    .args(&args)
2254                    .current_dir(&effective_cwd)
2255                    .stdout(std::process::Stdio::piped())
2256                    .stderr(std::process::Stdio::piped())
2257                    .hide_window()
2258                    .spawn()
2259                {
2260                    Ok(child) => child,
2261                    Err(e) => {
2262                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2263                            fresh_core::api::PluginAsyncMessage::ProcessExit {
2264                                process_id,
2265                                callback_id: callback_id_u64,
2266                                exit_code: -1,
2267                            },
2268                        ));
2269                        tracing::error!("Failed to spawn background process: {}", e);
2270                        return;
2271                    }
2272                };
2273
2274                // Stream stdout
2275                let stdout = child.stdout.take();
2276                let stderr = child.stderr.take();
2277                let pid = process_id;
2278
2279                // Spawn stdout reader
2280                if let Some(stdout) = stdout {
2281                    let sender = sender_stdout;
2282                    tokio::spawn(async move {
2283                        let reader = BufReader::new(stdout);
2284                        let mut lines = reader.lines();
2285                        while let Ok(Some(line)) = lines.next_line().await {
2286                            let _ =
2287                                sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2288                                    fresh_core::api::PluginAsyncMessage::ProcessStdout {
2289                                        process_id: pid,
2290                                        data: line + "\n",
2291                                    },
2292                                ));
2293                        }
2294                    });
2295                }
2296
2297                // Spawn stderr reader
2298                if let Some(stderr) = stderr {
2299                    let sender = sender_stderr;
2300                    tokio::spawn(async move {
2301                        let reader = BufReader::new(stderr);
2302                        let mut lines = reader.lines();
2303                        while let Ok(Some(line)) = lines.next_line().await {
2304                            let _ =
2305                                sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2306                                    fresh_core::api::PluginAsyncMessage::ProcessStderr {
2307                                        process_id: pid,
2308                                        data: line + "\n",
2309                                    },
2310                                ));
2311                        }
2312                    });
2313                }
2314
2315                // Wait for process to complete
2316                let exit_code = match child.wait().await {
2317                    Ok(status) => status.code().unwrap_or(-1),
2318                    Err(_) => -1,
2319                };
2320
2321                let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2322                    fresh_core::api::PluginAsyncMessage::ProcessExit {
2323                        process_id,
2324                        callback_id: callback_id_u64,
2325                        exit_code,
2326                    },
2327                ));
2328            });
2329
2330            // Store abort handle for potential kill
2331            self.background_process_handles
2332                .insert(process_id, handle.abort_handle());
2333        } else {
2334            // No runtime - reject immediately
2335            self.plugin_manager
2336                .read()
2337                .unwrap()
2338                .reject_callback(callback_id, "Async runtime not available".to_string());
2339        }
2340    }
2341
2342    fn handle_create_virtual_buffer_with_content(
2343        &mut self,
2344        name: String,
2345        mode: String,
2346        read_only: bool,
2347        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2348        show_line_numbers: bool,
2349        show_cursors: bool,
2350        editing_disabled: bool,
2351        hidden_from_tabs: bool,
2352        request_id: Option<u64>,
2353    ) {
2354        let buffer_id =
2355            self.active_window_mut()
2356                .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2357        tracing::info!(
2358            "Created virtual buffer '{}' with mode '{}' (id={:?})",
2359            name,
2360            mode,
2361            buffer_id
2362        );
2363
2364        // Apply view options to the buffer
2365        // TODO: show_line_numbers is duplicated between EditorState.margins and
2366        // BufferViewState. The renderer reads BufferViewState and overwrites
2367        // margins each frame via configure_for_line_numbers(), making the margin
2368        // setting here effectively write-only. Consider removing the margin call
2369        // and only setting BufferViewState.show_line_numbers.
2370        if let Some(state) = self
2371            .windows
2372            .get_mut(&self.active_window)
2373            .map(|w| &mut w.buffers)
2374            .expect("active window present")
2375            .get_mut(&buffer_id)
2376        {
2377            state.margins.configure_for_line_numbers(show_line_numbers);
2378            state.show_cursors = show_cursors;
2379            state.editing_disabled = editing_disabled;
2380            tracing::debug!(
2381                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
2382                        buffer_id,
2383                        show_line_numbers,
2384                        show_cursors,
2385                        editing_disabled
2386                    );
2387        }
2388        let active_split = self
2389            .windows
2390            .get(&self.active_window)
2391            .and_then(|w| w.buffers.splits())
2392            .map(|(mgr, _)| mgr)
2393            .expect("active window must have a populated split layout")
2394            .active_split();
2395        if let Some(view_state) = self
2396            .windows
2397            .get_mut(&self.active_window)
2398            .and_then(|w| w.split_view_states_mut())
2399            .expect("active window must have a populated split layout")
2400            .get_mut(&active_split)
2401        {
2402            view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2403        }
2404
2405        // Apply hidden_from_tabs to buffer metadata
2406        if hidden_from_tabs {
2407            if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2408                meta.hidden_from_tabs = true;
2409            }
2410        }
2411
2412        // Now set the content
2413        match self.set_virtual_buffer_content(buffer_id, entries) {
2414            Ok(()) => {
2415                tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
2416                // Switch to the new buffer to display it
2417                self.set_active_buffer(buffer_id);
2418                tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
2419
2420                // Send response if request_id is present
2421                if let Some(req_id) = request_id {
2422                    tracing::info!(
2423                                "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
2424                                req_id,
2425                                buffer_id
2426                            );
2427                    // createVirtualBuffer returns VirtualBufferResult: { bufferId, splitId }
2428                    let result = fresh_core::api::VirtualBufferResult {
2429                        buffer_id: buffer_id.0 as u64,
2430                        split_id: None,
2431                    };
2432                    self.plugin_manager.read().unwrap().resolve_callback(
2433                        fresh_core::api::JsCallbackId::from(req_id),
2434                        serde_json::to_string(&result).unwrap_or_default(),
2435                    );
2436                    tracing::info!(
2437                        "CreateVirtualBufferWithContent: resolve_callback sent for request_id={}",
2438                        req_id
2439                    );
2440                }
2441            }
2442            Err(e) => {
2443                tracing::error!("Failed to set virtual buffer content: {}", e);
2444            }
2445        }
2446    }
2447
2448    fn handle_create_virtual_buffer_in_split(
2449        &mut self,
2450        name: String,
2451        mode: String,
2452        read_only: bool,
2453        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2454        ratio: f32,
2455        direction: Option<String>,
2456        panel_id: Option<String>,
2457        show_line_numbers: bool,
2458        show_cursors: bool,
2459        editing_disabled: bool,
2460        line_wrap: Option<bool>,
2461        before: bool,
2462        role: Option<String>,
2463        request_id: Option<u64>,
2464    ) {
2465        // Resolve the role string. Unknown roles are silently dropped
2466        // (forward-compat for plugins targeting newer cores).
2467        let split_role: Option<crate::view::split::SplitRole> = match role.as_deref() {
2468            Some("utility_dock") => Some(crate::view::split::SplitRole::UtilityDock),
2469            _ => None,
2470        };
2471
2472        // Utility-dock fast path (issue #1796 / Section 2 of the design):
2473        // if a leaf with this role already exists, swap its active
2474        // buffer instead of spawning a fresh split. The buffer is
2475        // created normally, registered in `panel_ids`, and added as a
2476        // tab in the dock leaf.
2477        if let Some(target_role) = split_role {
2478            if let Some(dock_leaf) = self
2479                .windows
2480                .get(&self.active_window)
2481                .and_then(|w| w.buffers.splits())
2482                .map(|(mgr, _)| mgr)
2483                .expect("active window must have a populated split layout")
2484                .find_leaf_by_role(target_role)
2485            {
2486                // Capture the source split *before* create_virtual_buffer
2487                // tabs the new buffer into it; we drop that phantom tab
2488                // after the dock attach so the buffer only shows in the
2489                // dock.
2490                let source_split_before_create = self
2491                    .windows
2492                    .get(&self.active_window)
2493                    .and_then(|w| w.buffers.splits())
2494                    .map(|(mgr, _)| mgr)
2495                    .expect("active window must have a populated split layout")
2496                    .active_split();
2497                let buffer_id = self.active_window_mut().create_virtual_buffer(
2498                    name.clone(),
2499                    mode.clone(),
2500                    read_only,
2501                );
2502                if let Some(state) = self
2503                    .windows
2504                    .get_mut(&self.active_window)
2505                    .map(|w| &mut w.buffers)
2506                    .expect("active window present")
2507                    .get_mut(&buffer_id)
2508                {
2509                    state.margins.configure_for_line_numbers(show_line_numbers);
2510                    state.show_cursors = show_cursors;
2511                    state.editing_disabled = editing_disabled;
2512                }
2513                if let Some(pid) = &panel_id {
2514                    self.panel_ids_mut().insert(pid.clone(), buffer_id);
2515                }
2516                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2517                    tracing::error!("Failed to set virtual buffer content (dock route): {}", e);
2518                    return;
2519                }
2520
2521                // Swap the dock leaf's active buffer to the new one and
2522                // add it as a tab so the user can flip between
2523                // dock-resident utilities (Diagnostics ↔ Quickfix etc.).
2524                self.windows
2525                    .get_mut(&self.active_window)
2526                    .and_then(|w| w.split_manager_mut())
2527                    .expect("active window must have a populated split layout")
2528                    .set_active_split(dock_leaf);
2529                self.active_window_mut()
2530                    .set_pane_buffer(dock_leaf, buffer_id);
2531
2532                // Drop the phantom tab from the source split.
2533                if dock_leaf != source_split_before_create {
2534                    if let Some(source_view_state) = self
2535                        .windows
2536                        .get_mut(&self.active_window)
2537                        .and_then(|w| w.split_view_states_mut())
2538                        .expect("active window must have a populated split layout")
2539                        .get_mut(&source_split_before_create)
2540                    {
2541                        source_view_state.remove_buffer(buffer_id);
2542                    }
2543                }
2544
2545                if let Some(req_id) = request_id {
2546                    let result = fresh_core::api::VirtualBufferResult {
2547                        buffer_id: buffer_id.0 as u64,
2548                        split_id: Some(dock_leaf.0 .0 as u64),
2549                    };
2550                    self.plugin_manager.read().unwrap().resolve_callback(
2551                        fresh_core::api::JsCallbackId::from(req_id),
2552                        serde_json::to_string(&result).unwrap_or_default(),
2553                    );
2554                }
2555                tracing::info!(
2556                    "Routed virtual buffer '{}' into existing utility dock {:?}",
2557                    name,
2558                    dock_leaf
2559                );
2560                return;
2561            }
2562            // No dock yet — fall through to normal split creation,
2563            // then tag the new leaf with the requested role at the end.
2564        }
2565
2566        // Check if this panel already exists (for idempotent operations)
2567        if let Some(pid) = &panel_id {
2568            if let Some(&existing_buffer_id) = self.panel_ids().get(pid) {
2569                // Verify the buffer actually exists (defensive check for stale entries)
2570                if self
2571                    .windows
2572                    .get(&self.active_window)
2573                    .map(|w| &w.buffers)
2574                    .expect("active window present")
2575                    .contains_key(&existing_buffer_id)
2576                {
2577                    // Panel exists, just update its content
2578                    if let Err(e) = self.set_virtual_buffer_content(existing_buffer_id, entries) {
2579                        tracing::error!("Failed to update panel content: {}", e);
2580                    } else {
2581                        tracing::info!("Updated existing panel '{}' content", pid);
2582                    }
2583
2584                    // Find and focus the split that contains this buffer
2585                    let splits = self
2586                        .windows
2587                        .get(&self.active_window)
2588                        .and_then(|w| w.buffers.splits())
2589                        .map(|(mgr, _)| mgr)
2590                        .expect("active window must have a populated split layout")
2591                        .splits_for_buffer(existing_buffer_id);
2592                    if let Some(&split_id) = splits.first() {
2593                        self.windows
2594                            .get_mut(&self.active_window)
2595                            .and_then(|w| w.split_manager_mut())
2596                            .expect("active window must have a populated split layout")
2597                            .set_active_split(split_id);
2598                        // Route through set_pane_buffer so tree + SVS
2599                        // stay consistent (issue #1620 invariant).
2600                        self.active_window_mut()
2601                            .set_pane_buffer(split_id, existing_buffer_id);
2602                        tracing::debug!("Focused split {:?} containing panel buffer", split_id);
2603                    }
2604
2605                    // Send response with existing buffer ID and split ID via callback resolution
2606                    if let Some(req_id) = request_id {
2607                        let result = fresh_core::api::VirtualBufferResult {
2608                            buffer_id: existing_buffer_id.0 as u64,
2609                            split_id: splits.first().map(|s| s.0 .0 as u64),
2610                        };
2611                        self.plugin_manager.read().unwrap().resolve_callback(
2612                            fresh_core::api::JsCallbackId::from(req_id),
2613                            serde_json::to_string(&result).unwrap_or_default(),
2614                        );
2615                    }
2616                    return;
2617                } else {
2618                    // Buffer no longer exists, remove stale panel_id entry
2619                    tracing::warn!(
2620                        "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
2621                        pid,
2622                        existing_buffer_id
2623                    );
2624                    self.panel_ids_mut().remove(pid);
2625                    // Fall through to create a new buffer
2626                }
2627            }
2628        }
2629
2630        // Capture the source split before creating the buffer —
2631        // `create_virtual_buffer` unconditionally adds the new buffer
2632        // as a tab to the currently active split, which is the wrong
2633        // thing for a panel that lives in its own dedicated split
2634        // (it would show up as a tab in BOTH splits — see bug #3).
2635        let source_split_before_create = self
2636            .windows
2637            .get(&self.active_window)
2638            .and_then(|w| w.buffers.splits())
2639            .map(|(mgr, _)| mgr)
2640            .expect("active window must have a populated split layout")
2641            .active_split();
2642
2643        // Create the virtual buffer first
2644        let buffer_id =
2645            self.active_window_mut()
2646                .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2647        tracing::info!(
2648            "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
2649            name,
2650            mode,
2651            buffer_id
2652        );
2653
2654        // Apply view options to the buffer
2655        if let Some(state) = self
2656            .windows
2657            .get_mut(&self.active_window)
2658            .map(|w| &mut w.buffers)
2659            .expect("active window present")
2660            .get_mut(&buffer_id)
2661        {
2662            state.margins.configure_for_line_numbers(show_line_numbers);
2663            state.show_cursors = show_cursors;
2664            state.editing_disabled = editing_disabled;
2665            tracing::debug!(
2666                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
2667                        buffer_id,
2668                        show_line_numbers,
2669                        show_cursors,
2670                        editing_disabled
2671                    );
2672        }
2673
2674        // Store the panel ID mapping if provided
2675        if let Some(pid) = panel_id {
2676            self.panel_ids_mut().insert(pid, buffer_id);
2677        }
2678
2679        // Set the content
2680        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2681            tracing::error!("Failed to set virtual buffer content: {}", e);
2682            return;
2683        }
2684
2685        // Determine split direction
2686        let split_dir = match direction.as_deref() {
2687            Some("vertical") => crate::model::event::SplitDirection::Vertical,
2688            _ => crate::model::event::SplitDirection::Horizontal,
2689        };
2690
2691        // Create a split with the new buffer. When the caller asked
2692        // for `role = "utility_dock"` and no dock leaf exists yet,
2693        // split at the *root* so the dock spans the full width below
2694        // any pre-existing side-by-side panes — splitting the active
2695        // leaf would nest the dock under whichever pane was focused.
2696        let created_split_id =
2697            match if split_role == Some(crate::view::split::SplitRole::UtilityDock) {
2698                self.windows
2699                    .get_mut(&self.active_window)
2700                    .and_then(|w| w.split_manager_mut())
2701                    .expect("active window must have a populated split layout")
2702                    .split_root_positioned(split_dir, buffer_id, ratio, before)
2703            } else {
2704                self.windows
2705                    .get_mut(&self.active_window)
2706                    .and_then(|w| w.split_manager_mut())
2707                    .expect("active window must have a populated split layout")
2708                    .split_active_positioned(split_dir, buffer_id, ratio, before)
2709            } {
2710                Ok(new_split_id) => {
2711                    // The buffer now lives in its own split, so drop its
2712                    // tab from the source split (see bug #3).  Only do
2713                    // this when the new split actually differs from the
2714                    // source split — otherwise we'd leave no split
2715                    // displaying the buffer.
2716                    if new_split_id != source_split_before_create {
2717                        if let Some(source_view_state) = self
2718                            .windows
2719                            .get_mut(&self.active_window)
2720                            .and_then(|w| w.split_view_states_mut())
2721                            .expect("active window must have a populated split layout")
2722                            .get_mut(&source_split_before_create)
2723                        {
2724                            source_view_state.remove_buffer(buffer_id);
2725                        }
2726                    }
2727                    // Create independent view state for the new split with the buffer in tabs
2728                    let mut view_state = SplitViewState::with_buffer(
2729                        self.terminal_width,
2730                        self.terminal_height,
2731                        buffer_id,
2732                    );
2733                    view_state.apply_config_defaults(
2734                        self.config.editor.line_numbers,
2735                        self.config.editor.highlight_current_line,
2736                        line_wrap.unwrap_or_else(|| {
2737                            self.active_window().resolve_line_wrap_for_buffer(buffer_id)
2738                        }),
2739                        self.config.editor.wrap_indent,
2740                        self.active_window()
2741                            .resolve_wrap_column_for_buffer(buffer_id),
2742                        self.config.editor.rulers.clone(),
2743                    );
2744                    // Override with plugin-requested show_line_numbers
2745                    view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2746                    self.windows
2747                        .get_mut(&self.active_window)
2748                        .and_then(|w| w.split_view_states_mut())
2749                        .expect("active window must have a populated split layout")
2750                        .insert(new_split_id, view_state);
2751
2752                    // Focus the new split (the diagnostics panel)
2753                    self.windows
2754                        .get_mut(&self.active_window)
2755                        .and_then(|w| w.split_manager_mut())
2756                        .expect("active window must have a populated split layout")
2757                        .set_active_split(new_split_id);
2758                    // NOTE: split tree was updated by split_active, active_buffer derives from it
2759
2760                    // If a role was requested but no dock existed (we fell
2761                    // through the fast-path above), tag the freshly created
2762                    // leaf so the next utility lands here. Clear any stale
2763                    // role from elsewhere first to preserve the
2764                    // one-leaf-per-role invariant.
2765                    if let Some(target_role) = split_role {
2766                        self.windows
2767                            .get_mut(&self.active_window)
2768                            .and_then(|w| w.split_manager_mut())
2769                            .expect("active window must have a populated split layout")
2770                            .clear_role(target_role);
2771                        self.windows
2772                            .get_mut(&self.active_window)
2773                            .and_then(|w| w.split_manager_mut())
2774                            .expect("active window must have a populated split layout")
2775                            .set_leaf_role(new_split_id, Some(target_role));
2776                        tracing::info!(
2777                            "Tagged new dock leaf {:?} with role {:?}",
2778                            new_split_id,
2779                            target_role
2780                        );
2781                    }
2782
2783                    tracing::info!(
2784                        "Created {:?} split with virtual buffer {:?}",
2785                        split_dir,
2786                        buffer_id
2787                    );
2788                    Some(new_split_id)
2789                }
2790                Err(e) => {
2791                    tracing::error!("Failed to create split: {}", e);
2792                    // Fall back to just switching to the buffer
2793                    self.set_active_buffer(buffer_id);
2794                    None
2795                }
2796            };
2797
2798        // Send response with buffer ID and split ID via callback resolution
2799        // NOTE: Using VirtualBufferResult type for type-safe JSON serialization
2800        if let Some(req_id) = request_id {
2801            tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
2802            let result = fresh_core::api::VirtualBufferResult {
2803                buffer_id: buffer_id.0 as u64,
2804                split_id: created_split_id.map(|s| s.0 .0 as u64),
2805            };
2806            self.plugin_manager.read().unwrap().resolve_callback(
2807                fresh_core::api::JsCallbackId::from(req_id),
2808                serde_json::to_string(&result).unwrap_or_default(),
2809            );
2810        }
2811    }
2812
2813    fn handle_create_virtual_buffer_in_existing_split(
2814        &mut self,
2815        name: String,
2816        mode: String,
2817        read_only: bool,
2818        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2819        split_id: SplitId,
2820        show_line_numbers: bool,
2821        show_cursors: bool,
2822        editing_disabled: bool,
2823        line_wrap: Option<bool>,
2824        request_id: Option<u64>,
2825    ) {
2826        // Create the virtual buffer
2827        let buffer_id =
2828            self.active_window_mut()
2829                .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2830        tracing::info!(
2831            "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
2832            name,
2833            mode,
2834            split_id,
2835            buffer_id
2836        );
2837
2838        // Apply view options to the buffer
2839        if let Some(state) = self
2840            .windows
2841            .get_mut(&self.active_window)
2842            .map(|w| &mut w.buffers)
2843            .expect("active window present")
2844            .get_mut(&buffer_id)
2845        {
2846            state.margins.configure_for_line_numbers(show_line_numbers);
2847            state.show_cursors = show_cursors;
2848            state.editing_disabled = editing_disabled;
2849        }
2850
2851        // Set the content
2852        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2853            tracing::error!("Failed to set virtual buffer content: {}", e);
2854            return;
2855        }
2856
2857        // Show the buffer in the target split. set_pane_buffer
2858        // covers the tree + SVS updates the old code did by hand.
2859        let leaf_id = LeafId(split_id);
2860        self.windows
2861            .get_mut(&self.active_window)
2862            .and_then(|w| w.split_manager_mut())
2863            .expect("active window must have a populated split layout")
2864            .set_active_split(leaf_id);
2865        self.active_window_mut().set_pane_buffer(leaf_id, buffer_id);
2866
2867        // Fall-through to the cursor/open_buffers housekeeping
2868        // that used to follow the manual switch_buffer. We keep
2869        // the `if let Some(view_state)` block below — set_pane_buffer
2870        // already called switch_buffer, but the downstream code
2871        // also nudges open_buffers and focus_history.
2872        if let Some(view_state) = self
2873            .windows
2874            .get_mut(&self.active_window)
2875            .and_then(|w| w.split_view_states_mut())
2876            .expect("active window must have a populated split layout")
2877            .get_mut(&leaf_id)
2878        {
2879            view_state.switch_buffer(buffer_id);
2880            view_state.add_buffer(buffer_id);
2881            view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2882
2883            // Apply line_wrap setting if provided
2884            if let Some(wrap) = line_wrap {
2885                view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
2886            }
2887        }
2888
2889        tracing::info!(
2890            "Displayed virtual buffer {:?} in split {:?}",
2891            buffer_id,
2892            split_id
2893        );
2894
2895        // Send response with buffer ID and split ID via callback resolution
2896        if let Some(req_id) = request_id {
2897            let result = fresh_core::api::VirtualBufferResult {
2898                buffer_id: buffer_id.0 as u64,
2899                split_id: Some(split_id.0 as u64),
2900            };
2901            self.plugin_manager.read().unwrap().resolve_callback(
2902                fresh_core::api::JsCallbackId::from(req_id),
2903                serde_json::to_string(&result).unwrap_or_default(),
2904            );
2905        }
2906    }
2907
2908    fn handle_show_action_popup(
2909        &mut self,
2910        popup_id: String,
2911        title: String,
2912        message: String,
2913        actions: Vec<fresh_core::api::ActionPopupAction>,
2914    ) {
2915        tracing::info!(
2916            "Action popup requested: id={}, title={}, actions={}",
2917            popup_id,
2918            title,
2919            actions.len()
2920        );
2921
2922        // Build popup list items from actions
2923        let items: Vec<crate::model::event::PopupListItemData> = actions
2924            .iter()
2925            .map(|action| crate::model::event::PopupListItemData {
2926                text: action.label.clone(),
2927                detail: None,
2928                icon: None,
2929                data: Some(action.id.clone()),
2930            })
2931            .collect();
2932
2933        // The popup_id lives on the popup itself via its
2934        // `PopupResolver::PluginAction` — no side-channel stack.
2935        // Drop the incoming `actions` vec; its ids are already
2936        // encoded as each list item's `data` field below.
2937        drop(actions);
2938
2939        // Create popup with message + action list
2940        let popup_data = crate::model::event::PopupData {
2941            kind: crate::model::event::PopupKindHint::List,
2942            title: Some(title),
2943            description: Some(message),
2944            transient: false,
2945            content: crate::model::event::PopupContentData::List { items, selected: 0 },
2946            position: crate::model::event::PopupPositionData::BottomRight,
2947            width: 60,
2948            max_height: 15,
2949            bordered: true,
2950        };
2951
2952        // Action popups are buffer-independent notifications; route
2953        // them to the editor-level popup stack so they remain visible
2954        // (and dismissible) regardless of which buffer is focused —
2955        // including virtual buffers like the Dashboard that own the
2956        // whole split.
2957        //
2958        // The resolver carries the popup_id so confirm/cancel fires
2959        // `action_popup_result` for exactly THIS popup, even when
2960        // multiple plugin popups are stacked concurrently.
2961        let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
2962        popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
2963            popup_id: popup_id.clone(),
2964        };
2965
2966        // `convert_popup_data_to_popup` hardcodes a default dark
2967        // background because it has no theme handle (it's called from
2968        // `EditorState::apply` too). Restamp the active theme's
2969        // `popup_bg` / `popup_border_fg` here so plugin popups don't
2970        // render as a near-black rectangle on top of a light theme —
2971        // #1941 issue 2.
2972        {
2973            let theme = self.theme();
2974            popup_obj.background_style = ratatui::style::Style::default().bg(theme.popup_bg);
2975            popup_obj.border_style = ratatui::style::Style::default().fg(theme.popup_border_fg);
2976        }
2977
2978        // Dismiss any built-in LSP-status popup that the editor put
2979        // on `active_state().popups` in response to the same click —
2980        // the plugin's popup is the contextual answer and stacking
2981        // ours underneath leaves two popups for one user gesture
2982        // (#1941 issue 1). Done here (rather than at the
2983        // `show_lsp_status_popup` call site) because plugin handlers
2984        // run *asynchronously*: by the time the `ShowActionPopup`
2985        // command reaches us, the LSP-Servers popup has already
2986        // landed. Re-run on every plugin push (not just the first
2987        // dedup'd one) because rapid repeated clicks can re-add the
2988        // LSP-Servers popup between consecutive plugin commands.
2989        while self
2990            .active_state()
2991            .popups
2992            .top()
2993            .is_some_and(|p| matches!(p.resolver, crate::view::popup::PopupResolver::LspStatus))
2994        {
2995            self.active_state_mut().popups.hide();
2996        }
2997
2998        // Dedup by `popup_id`: if a previous `showActionPopup` with
2999        // the same id is still on the stack (common: repeated
3000        // indicator clicks fire `lsp_status_clicked` over and over,
3001        // each one re-pushing "rust-lsp-help"), replace it in place
3002        // instead of stacking another copy. Without this, dismissing
3003        // one reveals the same popup underneath — #1941 issue 4.
3004        let existing_idx = self.global_popups.all().iter().position(|p| {
3005            matches!(
3006                &p.resolver,
3007                crate::view::popup::PopupResolver::PluginAction { popup_id: id } if id == &popup_id,
3008            )
3009        });
3010        if let Some(idx) = existing_idx {
3011            if let Some(slot) = self.global_popups.get_mut(idx) {
3012                *slot = popup_obj;
3013            }
3014        } else {
3015            self.global_popups.show(popup_obj);
3016        }
3017        tracing::info!(
3018            "Action popup shown: id={}, stack_depth={}",
3019            popup_id,
3020            self.global_popups.all().len()
3021        );
3022    }
3023
3024    /// Install (or replace, or clear) a plugin's contributions for the
3025    /// LSP-Servers popup. Passing an empty `items` removes any
3026    /// previous contribution from this `plugin_id` for this
3027    /// `language`. Mirrors the editor-side half of
3028    /// `PluginCommand::SetLspMenuContributions`.
3029    ///
3030    /// If the LSP-Servers popup is currently open for this language,
3031    /// refresh it in place so the new rows show up immediately
3032    /// rather than only on the next click.
3033    fn handle_set_lsp_menu_contributions(
3034        &mut self,
3035        plugin_id: String,
3036        language: String,
3037        items: Vec<fresh_core::api::LspMenuItem>,
3038    ) {
3039        let key = (language.clone(), plugin_id.clone());
3040        if items.is_empty() {
3041            self.active_window_mut().lsp_menu_contributions.remove(&key);
3042        } else {
3043            self.active_window_mut()
3044                .lsp_menu_contributions
3045                .insert(key, items);
3046        }
3047        // If the popup is on screen right now, re-render it so the
3048        // change is immediately visible — the alternative is "next
3049        // click sees it" which feels unresponsive when the plugin
3050        // is reacting to an event the user just triggered.
3051        self.refresh_lsp_status_popup_if_open();
3052    }
3053
3054    fn handle_create_window_with_terminal(
3055        &mut self,
3056        root: std::path::PathBuf,
3057        label: String,
3058        cwd: Option<String>,
3059        command: Option<Vec<String>>,
3060        title: Option<String>,
3061        request_id: u64,
3062    ) {
3063        let callback_id = JsCallbackId::from(request_id);
3064        if !root.is_absolute() {
3065            let msg = format!(
3066                "createWindowWithTerminal: root must be absolute, got {:?}",
3067                root
3068            );
3069            tracing::warn!("{}", msg);
3070            self.plugin_manager
3071                .read()
3072                .unwrap()
3073                .reject_callback(callback_id, msg);
3074            return;
3075        }
3076        let cwd_buf = cwd.map(std::path::PathBuf::from);
3077        match self.create_window_with_terminal(root, label, cwd_buf, command, title) {
3078            Ok((window_id, terminal_id, buffer_id)) => {
3079                let api_result = fresh_core::api::SessionWithTerminalResult {
3080                    window_id: window_id.0,
3081                    terminal_id: terminal_id.0 as u64,
3082                    buffer_id: buffer_id.0 as u64,
3083                };
3084                self.plugin_manager.read().unwrap().resolve_callback(
3085                    callback_id,
3086                    serde_json::to_string(&api_result).unwrap_or_default(),
3087                );
3088            }
3089            Err(e) => {
3090                tracing::error!("createWindowWithTerminal failed: {e}");
3091                self.plugin_manager
3092                    .read()
3093                    .unwrap()
3094                    .reject_callback(callback_id, format!("createWindowWithTerminal: {e}"));
3095            }
3096        }
3097    }
3098
3099    fn handle_create_terminal(
3100        &mut self,
3101        cwd: Option<String>,
3102        direction: Option<String>,
3103        ratio: Option<f32>,
3104        focus: Option<bool>,
3105        persistent: bool,
3106        target_session_id: Option<fresh_core::WindowId>,
3107        command: Option<Vec<String>>,
3108        title: Option<String>,
3109        request_id: u64,
3110    ) {
3111        // Resolve target window. Explicit `windowId` wins when the
3112        // window exists; otherwise we operate on the active window.
3113        // Both cases route through `Window::create_plugin_terminal`
3114        // so spawning into an inactive session reuses the same code
3115        // path — no separate migration helper, no half-state leaks
3116        // between windows.
3117        let target_id = target_session_id
3118            .filter(|id| self.windows.contains_key(id))
3119            .unwrap_or(self.active_window);
3120        let is_active_target = target_id == self.active_window;
3121
3122        let cwd_buf = cwd.map(std::path::PathBuf::from);
3123        let split_direction = direction.as_deref().map(|d| match d {
3124            "horizontal" => crate::model::event::SplitDirection::Horizontal,
3125            _ => crate::model::event::SplitDirection::Vertical,
3126        });
3127
3128        // Capture the editor-active buffer before the spawn so we
3129        // can detect whether `Window::create_plugin_terminal`'s
3130        // per-window mutations also flipped the editor-active buffer
3131        // (only possible when `is_active_target`). If it did, the
3132        // `buffer_activated` plugin hook needs to fire here at the
3133        // Editor level — the Window method only mutates per-window
3134        // state.
3135        let prev_active = if is_active_target {
3136            Some(self.active_window().active_buffer())
3137        } else {
3138            None
3139        };
3140
3141        let result = {
3142            let target = self
3143                .windows
3144                .get_mut(&target_id)
3145                .expect("target window present (existence checked above)");
3146            target.create_plugin_terminal(
3147                cwd_buf,
3148                split_direction,
3149                ratio,
3150                focus.unwrap_or(true),
3151                persistent,
3152                command,
3153                title.filter(|t| !t.is_empty()),
3154            )
3155        };
3156        match result {
3157            Ok((terminal_id, buffer_id, created_split_id)) => {
3158                if is_active_target {
3159                    let new_active = self.active_window().active_buffer();
3160                    if prev_active != Some(new_active) {
3161                        #[cfg(feature = "plugins")]
3162                        self.update_plugin_state_snapshot();
3163                        #[cfg(feature = "plugins")]
3164                        self.plugin_manager.read().unwrap().run_hook(
3165                            "buffer_activated",
3166                            crate::services::plugins::hooks::HookArgs::BufferActivated {
3167                                buffer_id: new_active,
3168                            },
3169                        );
3170                    }
3171                }
3172                let api_result = fresh_core::api::TerminalResult {
3173                    buffer_id: buffer_id.0 as u64,
3174                    terminal_id: terminal_id.0 as u64,
3175                    split_id: created_split_id.map(|s| s.0 .0 as u64),
3176                };
3177                self.plugin_manager.read().unwrap().resolve_callback(
3178                    fresh_core::api::JsCallbackId::from(request_id),
3179                    serde_json::to_string(&api_result).unwrap_or_default(),
3180                );
3181                tracing::info!(
3182                    "Plugin created terminal {:?} with buffer {:?} in window {:?}",
3183                    terminal_id,
3184                    buffer_id,
3185                    target_id
3186                );
3187            }
3188            Err(e) => {
3189                tracing::error!("Failed to create terminal for plugin: {e}");
3190                self.plugin_manager.read().unwrap().reject_callback(
3191                    fresh_core::api::JsCallbackId::from(request_id),
3192                    format!("Failed to create terminal: {e}"),
3193                );
3194            }
3195        }
3196    }
3197
3198    // ==================== Extracted handlers for previously inline match arms ====================
3199
3200    fn handle_get_split_by_label(&mut self, label: String, request_id: u64) {
3201        let split_id = self
3202            .windows
3203            .get(&self.active_window)
3204            .and_then(|w| w.buffers.splits())
3205            .map(|(mgr, _)| mgr)
3206            .expect("active window must have a populated split layout")
3207            .find_split_by_label(&label);
3208        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
3209        let json =
3210            serde_json::to_string(&split_id.map(|s| s.0 .0)).unwrap_or_else(|_| "null".to_string());
3211        self.plugin_manager
3212            .read()
3213            .unwrap()
3214            .resolve_callback(callback_id, json);
3215    }
3216
3217    fn handle_set_buffer_show_cursors(&mut self, buffer_id: BufferId, show: bool) {
3218        if let Some(state) = self
3219            .windows
3220            .get_mut(&self.active_window)
3221            .map(|w| &mut w.buffers)
3222            .expect("active window present")
3223            .get_mut(&buffer_id)
3224        {
3225            state.show_cursors = show;
3226            // The plugin now owns this buffer's cursor visibility; stop
3227            // the widget runtime from overriding it on every repaint.
3228            state.cursor_visibility_locked = true;
3229        } else {
3230            tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
3231        }
3232    }
3233
3234    fn handle_override_theme_colors(
3235        &mut self,
3236        overrides: std::collections::HashMap<String, [u8; 3]>,
3237    ) {
3238        let pairs = overrides
3239            .into_iter()
3240            .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
3241        let applied = self.theme.write().unwrap().override_colors(pairs);
3242        if applied > 0 {
3243            // Diagnostics / semantic overlays bake RGB at creation time — rebuild
3244            // them so the override is visible everywhere on the next frame.
3245            self.reapply_all_overlays();
3246        }
3247    }
3248
3249    fn handle_await_next_key(&mut self, callback_id: fresh_core::api::JsCallbackId) {
3250        // If keys arrived during a key-capture window while no callback was
3251        // pending, drain the front-most buffered key and resolve immediately.
3252        // Otherwise enqueue the callback for the next live keypress.
3253        if let Some(payload) = self
3254            .active_window_mut()
3255            .pending_key_capture_buffer
3256            .pop_front()
3257        {
3258            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
3259            self.plugin_manager
3260                .read()
3261                .unwrap()
3262                .resolve_callback(callback_id, json);
3263        } else {
3264            self.active_window_mut()
3265                .pending_next_key_callbacks
3266                .push_back(callback_id);
3267        }
3268    }
3269
3270    fn handle_spawn_process(
3271        &mut self,
3272        command: String,
3273        args: Vec<String>,
3274        cwd: Option<String>,
3275        stdout_to: Option<std::path::PathBuf>,
3276        callback_id: fresh_core::api::JsCallbackId,
3277    ) {
3278        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3279            let effective_cwd = cwd.or_else(|| {
3280                std::env::current_dir()
3281                    .map(|p| p.to_string_lossy().to_string())
3282                    .ok()
3283            });
3284            let sender = bridge.sender();
3285            let spawner = self.authority.process_spawner.clone();
3286
3287            // Kill plumbing: register a oneshot keyed by process_id, same
3288            // pattern as handle_spawn_host_process. JS calls
3289            // `_killHostProcess(id)` → `handle_kill_host_process` fires
3290            // the tx; the spawner's `spawn_cancellable` races against rx.
3291            let process_id = callback_id.as_u64();
3292            let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
3293            self.host_process_handles.insert(process_id, kill_tx);
3294
3295            runtime.spawn(async move {
3296                #[allow(clippy::let_underscore_must_use)]
3297                let outcome = spawner
3298                    .spawn_cancellable(command, args, effective_cwd, stdout_to, kill_rx)
3299                    .await;
3300                match outcome {
3301                    Ok(result) => {
3302                        #[allow(clippy::let_underscore_must_use)]
3303                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
3304                            process_id,
3305                            stdout: result.stdout,
3306                            stderr: result.stderr,
3307                            exit_code: result.exit_code,
3308                        });
3309                    }
3310                    Err(e) => {
3311                        #[allow(clippy::let_underscore_must_use)]
3312                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
3313                            process_id,
3314                            stdout: String::new(),
3315                            stderr: e.to_string(),
3316                            exit_code: -1,
3317                        });
3318                    }
3319                }
3320            });
3321        } else {
3322            self.plugin_manager
3323                .read()
3324                .unwrap()
3325                .reject_callback(callback_id, "Async runtime not available".to_string());
3326        }
3327    }
3328
3329    fn handle_kill_host_process(&mut self, process_id: u64) {
3330        // Removing from the map gives us the oneshot sender. Firing it signals
3331        // the spawn task to start_kill() the child and reap. Unknown IDs are
3332        // intentionally silent — the process may have already exited.
3333        if let Some(tx) = self.host_process_handles.remove(&process_id) {
3334            #[allow(clippy::let_underscore_must_use)]
3335            let _ = tx.send(());
3336            tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
3337        } else {
3338            tracing::debug!(
3339                "KillHostProcess: unknown process_id={} (already exited?)",
3340                process_id
3341            );
3342        }
3343    }
3344
3345    fn handle_set_authority(&mut self, payload: serde_json::Value) {
3346        // Payload is opaque at the fresh-core layer; the concrete schema lives
3347        // in services::authority::AuthorityPayload so core stays ignorant of backend kinds.
3348        match serde_json::from_value::<crate::services::authority::AuthorityPayload>(payload) {
3349            Ok(parsed) => {
3350                // The new authority shares the editor's live trust + env
3351                // handles, so its spawners are gated and env'd identically.
3352                let trust = std::sync::Arc::clone(&self.authority.workspace_trust);
3353                let env = std::sync::Arc::clone(&self.authority.env_provider);
3354                match crate::services::authority::Authority::from_plugin_payload(parsed, trust, env)
3355                {
3356                    Ok(auth) => {
3357                        tracing::info!("Plugin installed new authority");
3358                        self.install_authority(auth);
3359                    }
3360                    Err(e) => {
3361                        tracing::warn!("setAuthority: invalid payload: {}", e);
3362                        self.set_status_message(format!("setAuthority rejected: {}", e));
3363                    }
3364                }
3365            }
3366            Err(e) => {
3367                tracing::warn!("setAuthority: failed to parse payload: {}", e);
3368                self.set_status_message(format!("setAuthority rejected: {}", e));
3369            }
3370        }
3371    }
3372
3373    fn handle_set_remote_indicator_state(&mut self, state: serde_json::Value) {
3374        // Opaque JSON at the fresh-core boundary; the concrete schema
3375        // (RemoteIndicatorOverride) lives in the view crate.
3376        match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(state)
3377        {
3378            Ok(over) => {
3379                self.remote_indicator_override = Some(over);
3380            }
3381            Err(e) => {
3382                tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
3383                self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
3384            }
3385        }
3386    }
3387
3388    fn handle_spawn_process_wait(
3389        &mut self,
3390        process_id: u64,
3391        callback_id: fresh_core::api::JsCallbackId,
3392    ) {
3393        tracing::warn!(
3394            "SpawnProcessWait not fully implemented - process_id={}",
3395            process_id
3396        );
3397        self.plugin_manager.read().unwrap().reject_callback(
3398            callback_id,
3399            format!(
3400                "SpawnProcessWait not yet fully implemented for process_id={}",
3401                process_id
3402            ),
3403        );
3404    }
3405
3406    fn handle_delay(&mut self, callback_id: fresh_core::api::JsCallbackId, duration_ms: u64) {
3407        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3408            let sender = bridge.sender();
3409            let callback_id_u64 = callback_id.as_u64();
3410            runtime.spawn(async move {
3411                tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
3412                #[allow(clippy::let_underscore_must_use)]
3413                let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
3414                    fresh_core::api::PluginAsyncMessage::DelayComplete {
3415                        callback_id: callback_id_u64,
3416                    },
3417                ));
3418            });
3419        } else {
3420            std::thread::sleep(std::time::Duration::from_millis(duration_ms));
3421            self.plugin_manager
3422                .read()
3423                .unwrap()
3424                .resolve_callback(callback_id, "null".to_string());
3425        }
3426    }
3427
3428    fn handle_http_fetch(
3429        &mut self,
3430        url: String,
3431        target_path: std::path::PathBuf,
3432        callback_id: fresh_core::api::JsCallbackId,
3433    ) {
3434        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3435            let sender = bridge.sender();
3436            let process_id = callback_id.as_u64();
3437
3438            runtime.spawn(async move {
3439                let fetch =
3440                    tokio::task::spawn_blocking(move || fetch_url_to_file(&url, &target_path))
3441                        .await;
3442
3443                let (stdout, stderr, exit_code) = match fetch {
3444                    Ok(Ok(status)) => {
3445                        if (200..300).contains(&status) {
3446                            (String::new(), String::new(), 0)
3447                        } else {
3448                            (String::new(), format!("HTTP {}", status), i32::from(status))
3449                        }
3450                    }
3451                    Ok(Err(e)) => (String::new(), e, -1),
3452                    Err(e) => (String::new(), format!("fetch task failed: {}", e), -1),
3453                };
3454
3455                #[allow(clippy::let_underscore_must_use)]
3456                let _ = sender.send(AsyncMessage::PluginProcessOutput {
3457                    process_id,
3458                    stdout,
3459                    stderr,
3460                    exit_code,
3461                });
3462            });
3463        } else {
3464            self.plugin_manager
3465                .read()
3466                .unwrap()
3467                .reject_callback(callback_id, "Async runtime not available".to_string());
3468        }
3469    }
3470
3471    fn handle_kill_background_process(&mut self, process_id: u64) {
3472        if let Some(handle) = self.background_process_handles.remove(&process_id) {
3473            handle.abort();
3474            tracing::debug!("Killed background process {}", process_id);
3475        }
3476    }
3477
3478    fn handle_create_virtual_buffer(&mut self, name: String, mode: String, read_only: bool) {
3479        let buffer_id =
3480            self.active_window_mut()
3481                .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3482        tracing::info!(
3483            "Created virtual buffer '{}' with mode '{}' (id={:?})",
3484            name,
3485            mode,
3486            buffer_id
3487        );
3488        // TODO: Return buffer_id to plugin via callback or hook
3489    }
3490
3491    fn handle_set_virtual_buffer_content(
3492        &mut self,
3493        buffer_id: BufferId,
3494        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3495    ) {
3496        match self.set_virtual_buffer_content(buffer_id, entries) {
3497            Ok(()) => {
3498                tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
3499            }
3500            Err(e) => {
3501                tracing::error!("Failed to set virtual buffer content: {}", e);
3502            }
3503        }
3504    }
3505
3506    fn handle_mount_widget_panel(
3507        &mut self,
3508        panel_id: u64,
3509        buffer_id: BufferId,
3510        spec: fresh_core::api::WidgetSpec,
3511    ) {
3512        // Mount = clean slate. Instance state and focus key reset
3513        // so a plugin that re-mounts (e.g. reopening a panel with
3514        // a fresh prefill) sees its spec values take effect. To
3515        // *preserve* state across renders, the plugin uses Update.
3516        let prev = std::collections::HashMap::new();
3517        let prev_focus = String::new();
3518        let panel_width = self.widget_panel_width(buffer_id);
3519        let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
3520        let focus_cursor = out.focus_cursor;
3521        self.widget_registry.mount(
3522            panel_id,
3523            buffer_id,
3524            spec,
3525            out.hits,
3526            out.instance_states,
3527            out.focus_key,
3528            out.tabbable,
3529        );
3530        let entries = out.entries;
3531        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3532            tracing::error!(
3533                "Failed to render mounted widget panel {} into {:?}: {}",
3534                panel_id,
3535                buffer_id,
3536                e
3537            );
3538        } else {
3539            tracing::debug!(
3540                "Mounted widget panel {} into buffer {:?}",
3541                panel_id,
3542                buffer_id
3543            );
3544        }
3545        self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3546    }
3547
3548    fn handle_update_widget_panel(&mut self, panel_id: u64, spec: fresh_core::api::WidgetSpec) {
3549        let prev = match self.widget_registry.instance_states(panel_id) {
3550            Some(s) => s.clone(),
3551            None => {
3552                tracing::debug!(
3553                    "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
3554                    panel_id
3555                );
3556                return;
3557            }
3558        };
3559        let prev_focus = self
3560            .widget_registry
3561            .focus_key(panel_id)
3562            .map(|s| s.to_string())
3563            .unwrap_or_default();
3564        let buffer_id_for_width = self
3565            .widget_registry
3566            .buffer_and_spec(panel_id)
3567            .map(|(b, _)| b)
3568            .unwrap_or(BufferId(0));
3569        let panel_width = self.widget_panel_width(buffer_id_for_width);
3570        let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
3571        let focus_cursor = out.focus_cursor;
3572        let entries = out.entries;
3573        match self.widget_registry.update(
3574            panel_id,
3575            spec,
3576            out.hits,
3577            out.instance_states,
3578            out.focus_key,
3579            out.tabbable,
3580        ) {
3581            Ok(buffer_id) => {
3582                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3583                    tracing::error!("Failed to render updated widget panel {}: {}", panel_id, e);
3584                }
3585                self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3586            }
3587            Err(()) => {
3588                tracing::debug!(
3589                    "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
3590                    panel_id
3591                );
3592            }
3593        }
3594    }
3595
3596    /// Apply a `RenderOutput`'s focus-cursor position to the panel
3597    /// buffer + every split rendering it. When a `TextInput` is
3598    /// focused, the dispatcher flips `show_cursors=true` and moves
3599    /// the primary cursor to the right byte. When no TextInput is
3600    /// focused, the cursor is hidden (`show_cursors=false`) — the
3601    /// focused widget's own bg overlay shows where focus is.
3602    ///
3603    /// Must be called *after* `set_virtual_buffer_content` so the
3604    /// buffer's text matches the row/byte coordinates the renderer
3605    /// produced.
3606    fn apply_widget_focus_cursor(
3607        &mut self,
3608        buffer_id: BufferId,
3609        entries: &[fresh_core::text_property::TextPropertyEntry],
3610        focus_cursor: Option<crate::widgets::FocusCursor>,
3611    ) {
3612        // If the plugin has taken explicit control of this buffer's cursor
3613        // (via `setBufferShowCursors`), the widget runtime must not touch
3614        // its visibility or position — the plugin owns it. This lets a
3615        // widget-panel pane be cursor-driven (e.g. git log's commit list)
3616        // without each repaint clearing the cursor.
3617        let locked = self
3618            .windows
3619            .get(&self.active_window)
3620            .and_then(|w| w.buffers.get(&buffer_id))
3621            .map(|s| s.cursor_visibility_locked)
3622            .unwrap_or(false);
3623        if locked {
3624            return;
3625        }
3626
3627        let absolute_byte = focus_cursor.map(|fc| {
3628            let row = fc.buffer_row as usize;
3629            let prefix: usize = entries.iter().take(row).map(|e| e.text.len()).sum();
3630            prefix + fc.byte_in_row as usize
3631        });
3632
3633        if let Some(state) = self
3634            .windows
3635            .get_mut(&self.active_window)
3636            .map(|w| &mut w.buffers)
3637            .expect("active window present")
3638            .get_mut(&buffer_id)
3639        {
3640            state.show_cursors = absolute_byte.is_some();
3641        }
3642
3643        if let Some(byte) = absolute_byte {
3644            for vs in self
3645                .windows
3646                .get_mut(&self.active_window)
3647                .and_then(|w| w.split_view_states_mut())
3648                .expect("active window must have a populated split layout")
3649                .values_mut()
3650            {
3651                if vs.buffer_state(buffer_id).is_some() {
3652                    let cursor = vs.cursors.primary_mut();
3653                    cursor.position = byte;
3654                }
3655            }
3656        }
3657    }
3658
3659    /// Best-effort width for a buffer's containing split. Returns
3660    /// the most recent `SplitViewState::viewport.width` for any
3661    /// split rendering this buffer; falls back to terminal width
3662    /// when the buffer hasn't been rendered yet (e.g. mid-mount).
3663    /// Subtracts 2 columns to account for gutter/scrollbar/border
3664    /// padding the renderer adds — leaving the right edge clear
3665    /// instead of pushing content into the chrome. This is what
3666    /// flex `Spacer`s inside `Row` use to size their fill.
3667    fn widget_panel_width(&self, buffer_id: BufferId) -> u32 {
3668        let raw = self
3669            .windows
3670            .get(&self.active_window)
3671            .and_then(|w| w.buffers.splits())
3672            .map(|(_, vs)| vs)
3673            .expect("active window must have a populated split layout")
3674            .values()
3675            .find(|vs| vs.buffer_state(buffer_id).is_some() && vs.viewport.width > 0)
3676            .map(|vs| vs.viewport.width as u32)
3677            .unwrap_or_else(|| self.terminal_width.max(1) as u32);
3678        // Reserve 2 cols for gutter/scrollbar/border. Saturate to
3679        // avoid 0 width on tiny panels.
3680        raw.saturating_sub(2).max(10)
3681    }
3682
3683    /// Re-render an existing widget panel after an in-host state
3684    /// change (focus advance, scroll move, etc.) without the plugin
3685    /// re-emitting the spec. Reads the panel's current spec from
3686    /// the registry, runs `render_spec` against the (possibly
3687    /// updated) prev state / focus key, writes the result back.
3688    pub(super) fn rerender_widget_panel(&mut self, panel_id: u64) {
3689        // The spec already lives in the registry — mutations (e.g.
3690        // `append_tree_nodes_in_spec`) edit it in place. Borrow it for
3691        // render, then write back only the side-effects (hits, instance
3692        // states, focus key, tabbable). The previous shape cloned the
3693        // whole spec out, rendered, then moved it back — for a Tree
3694        // with 5 000 nodes that's a multi-MB deep clone per IPC, which
3695        // dominates the host's per-mutation cost during a streaming
3696        // search.
3697        let (buffer_id, is_floating, panel_width, out_pieces) = {
3698            let (buffer_id, spec) = match self.widget_registry.buffer_and_spec_ref(panel_id) {
3699                Some(s) => s,
3700                None => return,
3701            };
3702            let prev = self
3703                .widget_registry
3704                .instance_states(panel_id)
3705                .cloned()
3706                .unwrap_or_default();
3707            let prev_focus = self
3708                .widget_registry
3709                .focus_key(panel_id)
3710                .map(|s| s.to_string())
3711                .unwrap_or_default();
3712            let is_floating = buffer_id == FLOATING_PANEL_BUFFER_ID;
3713            let panel_width = if is_floating {
3714                self.floating_panel_inner_width()
3715            } else {
3716                self.widget_panel_width(buffer_id)
3717            };
3718            let out = crate::widgets::render_spec(spec, &prev, &prev_focus, panel_width);
3719            (buffer_id, is_floating, panel_width, out)
3720        };
3721        let _ = panel_width;
3722        let focus_cursor = out_pieces.focus_cursor;
3723        let entries = out_pieces.entries;
3724        let embeds = out_pieces.embeds;
3725        let overlays = out_pieces.overlays;
3726        let scroll_regions = out_pieces.scroll_regions;
3727        if self
3728            .widget_registry
3729            .update_side_effects(
3730                panel_id,
3731                out_pieces.hits,
3732                out_pieces.instance_states,
3733                out_pieces.focus_key,
3734                out_pieces.tabbable,
3735            )
3736            .is_err()
3737        {
3738            tracing::warn!("rerender_widget_panel({}) lost panel mid-call", panel_id);
3739            return;
3740        }
3741        if is_floating {
3742            if let Some(fwp) = self.floating_widget_panel.as_mut() {
3743                if fwp.panel_id == panel_id {
3744                    fwp.entries = entries;
3745                    fwp.focus_cursor = focus_cursor;
3746                    fwp.embeds = embeds;
3747                    fwp.overlays = overlays;
3748                    fwp.scroll_regions = scroll_regions;
3749                }
3750            }
3751            return;
3752        }
3753        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3754            tracing::error!("rerender_widget_panel({}) failed: {}", panel_id, e);
3755        }
3756        self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3757    }
3758
3759    /// Apply a `WidgetMutation` in place, then re-render the panel.
3760    /// This is the IPC fast path: the plugin doesn't re-transmit
3761    /// the full spec; it sends one targeted change. The host
3762    /// mutates the registry's spec / instance state and re-renders
3763    /// against the just-mutated state.
3764    fn handle_widget_mutate(&mut self, panel_id: u64, mutation: fresh_core::api::WidgetMutation) {
3765        use fresh_core::api::WidgetMutation;
3766
3767        // Look up the panel; bail if unknown.
3768        if self.widget_registry.get(panel_id).is_none() {
3769            tracing::debug!(
3770                "WidgetMutate for unknown panel {} ignored (not mounted)",
3771                panel_id
3772            );
3773            return;
3774        }
3775
3776        match mutation {
3777            WidgetMutation::SetValue {
3778                widget_key,
3779                value,
3780                cursor_byte,
3781            } => {
3782                // Value+cursor live in instance state for the unified
3783                // Text widget. Preserve `scroll` and `multiline` from
3784                // the previous editor across the mutation so
3785                // multi-line viewport offsets don't snap on a
3786                // plugin-driven update; the renderer re-clamps next
3787                // render anyway.
3788                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3789                    // Preserve `scroll` + `multiline` so plugin-
3790                    // driven SetValue doesn't snap the viewport,
3791                    // and preserve `completions` /
3792                    // `completion_selected_index` so the popup
3793                    // (if open) doesn't disappear on a value
3794                    // mutation that happens to land while the
3795                    // user is mid-keystroke.
3796                    let (scroll, multiline, completions, sel_idx, scroll_off) =
3797                        match panel.instance_states.get(&widget_key) {
3798                            Some(crate::widgets::WidgetInstanceState::Text {
3799                                editor,
3800                                scroll,
3801                                completions,
3802                                completion_selected_index,
3803                                completion_scroll_offset,
3804                            }) => (
3805                                *scroll,
3806                                editor.multiline,
3807                                completions.clone(),
3808                                *completion_selected_index,
3809                                *completion_scroll_offset,
3810                            ),
3811                            _ => (0u32, true, Vec::new(), 0usize, 0u32),
3812                        };
3813                    let mut editor = if multiline {
3814                        crate::primitives::text_edit::TextEdit::with_text(&value)
3815                    } else {
3816                        crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
3817                    };
3818                    let target = match cursor_byte {
3819                        Some(c) if c >= 0 => (c as usize).min(value.len()),
3820                        _ => value.len(),
3821                    };
3822                    editor.set_cursor_from_flat(target);
3823                    panel.instance_states.insert(
3824                        widget_key,
3825                        crate::widgets::WidgetInstanceState::Text {
3826                            editor,
3827                            scroll,
3828                            completions,
3829                            completion_selected_index: sel_idx,
3830                            completion_scroll_offset: scroll_off,
3831                        },
3832                    );
3833                }
3834            }
3835            WidgetMutation::SetChecked {
3836                widget_key,
3837                checked,
3838            } => {
3839                // Toggle checked lives in the spec (not instance
3840                // state). Walk the spec, find the Toggle by key,
3841                // mutate.
3842                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3843                    crate::widgets::set_toggle_checked_in_spec(
3844                        &mut panel.spec,
3845                        &widget_key,
3846                        checked,
3847                    );
3848                }
3849            }
3850            WidgetMutation::SetSelectedIndex { widget_key, index } => {
3851                // List selected_index lives in instance state.
3852                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3853                    let prev_scroll = match panel.instance_states.get(&widget_key) {
3854                        Some(crate::widgets::WidgetInstanceState::List {
3855                            scroll_offset, ..
3856                        }) => *scroll_offset,
3857                        _ => 0,
3858                    };
3859                    panel.instance_states.insert(
3860                        widget_key,
3861                        crate::widgets::WidgetInstanceState::List {
3862                            scroll_offset: prev_scroll,
3863                            selected_index: index,
3864                        },
3865                    );
3866                }
3867            }
3868            WidgetMutation::SetCompletions { widget_key, items } => {
3869                // Update completion popup state on a Text widget.
3870                // Non-empty `items` opens the popup and resets the
3871                // host-managed selection to the top candidate;
3872                // empty closes it. The instance state has to
3873                // exist first (a SetCompletions arriving before
3874                // any render is dropped on the floor — Text
3875                // instance state is seeded on first render of
3876                // the spec).
3877                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3878                    if let Some(crate::widgets::WidgetInstanceState::Text {
3879                        completions,
3880                        completion_selected_index,
3881                        completion_scroll_offset,
3882                        ..
3883                    }) = panel.instance_states.get_mut(&widget_key)
3884                    {
3885                        *completions = items;
3886                        *completion_selected_index = 0;
3887                        *completion_scroll_offset = 0;
3888                    }
3889                }
3890            }
3891            WidgetMutation::SetItems {
3892                widget_key,
3893                items,
3894                item_keys,
3895            } => {
3896                // List items live in the spec.
3897                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3898                    crate::widgets::set_list_items_in_spec(
3899                        &mut panel.spec,
3900                        &widget_key,
3901                        items,
3902                        item_keys,
3903                    );
3904                }
3905            }
3906            WidgetMutation::SetExpandedKeys { widget_key, keys } => {
3907                // Tree expanded_keys lives in instance state.
3908                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3909                    let (prev_scroll, prev_sel) = match panel.instance_states.get(&widget_key) {
3910                        Some(crate::widgets::WidgetInstanceState::Tree {
3911                            scroll_offset,
3912                            selected_index,
3913                            ..
3914                        }) => (*scroll_offset, *selected_index),
3915                        _ => (0, -1),
3916                    };
3917                    let expanded: std::collections::HashSet<String> = keys.into_iter().collect();
3918                    panel.instance_states.insert(
3919                        widget_key,
3920                        crate::widgets::WidgetInstanceState::Tree {
3921                            scroll_offset: prev_scroll,
3922                            selected_index: prev_sel,
3923                            expanded_keys: expanded,
3924                        },
3925                    );
3926                }
3927            }
3928            WidgetMutation::SetCheckedKeys {
3929                widget_key,
3930                checked,
3931                keys,
3932            } => {
3933                // Tree node `checked` lives in the spec (not instance
3934                // state) — the plugin is the source of truth and can
3935                // re-derive the boolean from its model on every spec
3936                // emit. The mutator just stamps the new value into the
3937                // matching nodes so the next render reflects it
3938                // immediately, without round-tripping through the
3939                // plugin.
3940                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3941                    crate::widgets::set_tree_checked_keys_in_spec(
3942                        &mut panel.spec,
3943                        &widget_key,
3944                        checked,
3945                        &keys,
3946                    );
3947                }
3948            }
3949            WidgetMutation::AppendTreeNodes {
3950                widget_key,
3951                new_nodes,
3952                new_item_keys,
3953            } => {
3954                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3955                    crate::widgets::append_tree_nodes_in_spec(
3956                        &mut panel.spec,
3957                        &widget_key,
3958                        new_nodes,
3959                        new_item_keys,
3960                    );
3961                }
3962            }
3963            WidgetMutation::SetRawEntries {
3964                widget_key,
3965                entries,
3966            } => {
3967                if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3968                    crate::widgets::set_raw_entries_in_spec(&mut panel.spec, &widget_key, entries);
3969                }
3970            }
3971            WidgetMutation::SetFocusKey { widget_key } => {
3972                // Panel-level focus lives in the registry, not the
3973                // spec. The renderer reads it on the next paint and
3974                // re-clamps to the first tabbable if the key isn't a
3975                // current tabbable, so an unknown key is a safe no-op.
3976                self.widget_registry.set_focus_key(panel_id, widget_key);
3977            }
3978        }
3979
3980        // Re-render with the mutated state. `rerender_widget_panel`
3981        // reads the registry's current spec + instance state and
3982        // pushes the result through the buffer.
3983        self.rerender_widget_panel(panel_id);
3984    }
3985
3986    pub(super) fn handle_widget_command(
3987        &mut self,
3988        panel_id: u64,
3989        action: fresh_core::api::WidgetAction,
3990    ) {
3991        use fresh_core::api::WidgetAction;
3992        match action {
3993            WidgetAction::FocusAdvance { delta } => {
3994                self.handle_widget_focus_advance(panel_id, delta);
3995            }
3996            WidgetAction::Activate => {
3997                self.handle_widget_activate(panel_id);
3998            }
3999            WidgetAction::SelectMove { delta } => {
4000                self.handle_widget_select_move(panel_id, delta);
4001            }
4002            WidgetAction::TextInputKey { key } => {
4003                self.handle_widget_text_key(panel_id, &key);
4004            }
4005            WidgetAction::TextInputChar { text } => {
4006                self.handle_widget_text_char(panel_id, &text);
4007            }
4008            WidgetAction::Key { key } => {
4009                self.handle_widget_key(panel_id, &key);
4010            }
4011        }
4012    }
4013
4014    fn handle_widget_key(&mut self, panel_id: u64, key: &str) {
4015        // Smart key dispatch — route to the right specialized
4016        // handler based on focused widget kind. See WidgetAction::Key
4017        // doc for the dispatch table.
4018        let panel = match self.widget_registry.get(panel_id) {
4019            Some(p) => p,
4020            None => return,
4021        };
4022        let focus_key = panel.focus_key.clone();
4023        let widget = if focus_key.is_empty() {
4024            None
4025        } else {
4026            crate::widgets::find_widget_by_key(&panel.spec, &focus_key)
4027        };
4028        // Completion-popup short-circuit: when the focused Text
4029        // widget has an open completion popup, intercept Tab /
4030        // Up / Down / Enter / Esc so they drive the popup instead
4031        // of falling through to the widget's default key
4032        // behaviour. Tab fires `completion_accept`, Enter/Esc
4033        // dismiss, Up/Down move the host-managed selection. Any
4034        // other key (printable, Backspace, etc.) still goes to
4035        // the text editor, which lets the user keep typing to
4036        // refine the candidate list.
4037        let completions_open = matches!(key, "Tab" | "Up" | "Down" | "Enter" | "Escape")
4038            && self.focused_text_completions_open(panel_id);
4039        if completions_open {
4040            match key {
4041                "Tab" => {
4042                    self.fire_completion_accept(panel_id);
4043                    // The plugin's accept handler typically calls
4044                    // setValue + (maybe) setCompletions — those
4045                    // mutations re-render on their own, so we
4046                    // don't force a render here.
4047                    return;
4048                }
4049                "Up" => {
4050                    self.move_focused_text_completion_index(panel_id, -1);
4051                    // Selection moved host-side; force a repaint
4052                    // so the highlight + scroll-into-view shift
4053                    // is visible without waiting for the next
4054                    // unrelated mutation.
4055                    self.rerender_widget_panel(panel_id);
4056                    return;
4057                }
4058                "Down" => {
4059                    self.move_focused_text_completion_index(panel_id, 1);
4060                    self.rerender_widget_panel(panel_id);
4061                    return;
4062                }
4063                "Enter" | "Escape" => {
4064                    self.dismiss_focused_text_completions(panel_id);
4065                    self.rerender_widget_panel(panel_id);
4066                    return;
4067                }
4068                _ => {}
4069            }
4070        }
4071        match key {
4072            "Tab" => self.handle_widget_focus_advance(panel_id, 1),
4073            "Shift+Tab" => self.handle_widget_focus_advance(panel_id, -1),
4074            "Up" | "Down" => {
4075                let delta = if key == "Up" { -1 } else { 1 };
4076                match widget {
4077                    Some(fresh_core::api::WidgetSpec::List { .. }) => {
4078                        self.handle_widget_select_move(panel_id, delta);
4079                    }
4080                    Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4081                        self.handle_widget_tree_select_move(panel_id, delta);
4082                    }
4083                    Some(fresh_core::api::WidgetSpec::Text { rows, .. }) if *rows > 1 => {
4084                        // Multi-line Text: line nav. Single-line
4085                        // is filtered out — TextEdit::move_up /
4086                        // move_down would no-op on the single
4087                        // line, but skipping the dispatch keeps
4088                        // the change-event quiet.
4089                        self.handle_widget_text_key(panel_id, key);
4090                    }
4091                    _ => {
4092                        // Picker-style nav: when the focused widget
4093                        // doesn't have a meaningful Up/Down (single-
4094                        // line Text, Button, Toggle, or no focus),
4095                        // route the arrow to the first scrollable
4096                        // widget in the panel. Lets a filter input
4097                        // stay focused for typing while arrows
4098                        // navigate the adjacent list.
4099                        let scrollable = self
4100                            .widget_registry
4101                            .get(panel_id)
4102                            .and_then(|p| find_scrollable_widget_key(&p.spec));
4103                        if let Some(target_key) = scrollable {
4104                            let target_kind = self.widget_registry.get(panel_id).and_then(|p| {
4105                                crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
4106                            });
4107                            match target_kind {
4108                                Some(fresh_core::api::WidgetSpec::List { .. }) => {
4109                                    self.handle_widget_select_move_for_key(
4110                                        panel_id,
4111                                        &target_key,
4112                                        delta,
4113                                    );
4114                                }
4115                                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4116                                    self.handle_widget_tree_select_move_for_key(
4117                                        panel_id,
4118                                        &target_key,
4119                                        delta,
4120                                    );
4121                                }
4122                                _ => {}
4123                            }
4124                        }
4125                    }
4126                }
4127            }
4128            "PageUp" | "PageDown" => {
4129                // Page step = visible_rows - 1 (one row of overlap so
4130                // the user keeps a visual anchor across pages). Ignored
4131                // for non-scrollable widgets.
4132                let page = match widget {
4133                    Some(fresh_core::api::WidgetSpec::List { visible_rows, .. })
4134                    | Some(fresh_core::api::WidgetSpec::Tree { visible_rows, .. }) => {
4135                        visible_rows.saturating_sub(1).max(1) as i32
4136                    }
4137                    _ => 0,
4138                };
4139                if page == 0 {
4140                    return;
4141                }
4142                let delta = if key == "PageUp" { -page } else { page };
4143                match widget {
4144                    Some(fresh_core::api::WidgetSpec::List { .. }) => {
4145                        self.handle_widget_select_move(panel_id, delta);
4146                    }
4147                    Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4148                        self.handle_widget_tree_select_move(panel_id, delta);
4149                    }
4150                    _ => {}
4151                }
4152            }
4153            "Left" | "Right" => match widget {
4154                Some(fresh_core::api::WidgetSpec::Text { .. }) => {
4155                    self.handle_widget_text_key(panel_id, key);
4156                }
4157                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4158                    self.handle_widget_tree_lateral(panel_id, key == "Right");
4159                }
4160                _ => {}
4161            },
4162            "Backspace" | "Delete" | "Home" | "End" => match widget {
4163                Some(fresh_core::api::WidgetSpec::Text { .. }) => {
4164                    self.handle_widget_text_key(panel_id, key);
4165                }
4166                _ => {}
4167            },
4168            "Enter" => match widget {
4169                Some(fresh_core::api::WidgetSpec::Button { .. })
4170                | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
4171                    self.handle_widget_activate(panel_id);
4172                }
4173                Some(fresh_core::api::WidgetSpec::List { .. }) => {
4174                    self.fire_list_activate(panel_id, &focus_key);
4175                }
4176                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4177                    self.fire_tree_activate(panel_id, &focus_key);
4178                }
4179                Some(fresh_core::api::WidgetSpec::Text { rows, .. }) => {
4180                    if *rows > 1 {
4181                        // Multi-line: Enter inserts a newline at the
4182                        // cursor. Plugins that want Enter to submit
4183                        // can intercept it in their mode binding
4184                        // before dispatching through the smart-key
4185                        // router.
4186                        self.handle_widget_text_key(panel_id, "Enter");
4187                    } else if let Some(target_key) = self
4188                        .widget_registry
4189                        .get(panel_id)
4190                        .and_then(|p| find_scrollable_widget_key(&p.spec))
4191                    {
4192                        // Picker-style activate: a single-line filter
4193                        // input paired with a List/Tree fires that
4194                        // scrollable's activate event on Enter, so the
4195                        // user can type-then-Enter without tabbing
4196                        // focus to the list.
4197                        let kind = self.widget_registry.get(panel_id).and_then(|p| {
4198                            crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
4199                        });
4200                        match kind {
4201                            Some(fresh_core::api::WidgetSpec::List { .. }) => {
4202                                self.fire_list_activate(panel_id, &target_key);
4203                            }
4204                            Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4205                                self.fire_tree_activate(panel_id, &target_key);
4206                            }
4207                            _ => {}
4208                        }
4209                    } else {
4210                        // Form-like UX: Enter commits the field and
4211                        // moves to the next tabbable widget.
4212                        self.handle_widget_focus_advance(panel_id, 1);
4213                    }
4214                }
4215                _ => {}
4216            },
4217            "Space" => match widget {
4218                Some(fresh_core::api::WidgetSpec::Button { .. })
4219                | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
4220                    self.handle_widget_activate(panel_id);
4221                }
4222                Some(fresh_core::api::WidgetSpec::Text { .. }) => {
4223                    self.handle_widget_text_char(panel_id, " ");
4224                }
4225                Some(fresh_core::api::WidgetSpec::List { .. }) => {
4226                    self.fire_list_activate(panel_id, &focus_key);
4227                }
4228                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4229                    // On a checkable Tree, Space is the conventional
4230                    // checkbox key — fire `toggle` for the focused row
4231                    // (matching what a click on its `[v]`/`[ ]` glyph
4232                    // would do). Falls back to `activate` for trees
4233                    // that aren't checkable, or rows that don't have
4234                    // a checkbox glyph (`checked: None`).
4235                    if !self.fire_tree_toggle_if_checkable(panel_id, &focus_key) {
4236                        self.fire_tree_activate(panel_id, &focus_key);
4237                    }
4238                }
4239                _ => {}
4240            },
4241            _ => {} // unrecognised key — quietly ignore
4242        }
4243    }
4244
4245    fn handle_widget_focus_advance(&mut self, panel_id: u64, delta: i32) {
4246        let panel = match self.widget_registry.get(panel_id) {
4247            Some(p) => p,
4248            None => return,
4249        };
4250        if panel.tabbable.is_empty() {
4251            return;
4252        }
4253        let cur_idx = panel
4254            .tabbable
4255            .iter()
4256            .position(|k| k == &panel.focus_key)
4257            .unwrap_or(0) as i32;
4258        let n = panel.tabbable.len() as i32;
4259        let new_idx = ((cur_idx + delta) % n + n) % n;
4260        let new_key = panel.tabbable[new_idx as usize].clone();
4261        self.set_panel_focus_and_notify(panel_id, new_key);
4262        self.rerender_widget_panel(panel_id);
4263    }
4264
4265    /// Update the panel's focused widget AND fire a
4266    /// `widget_event { event_type: "focus" }` so plugins can
4267    /// react. Used by every host-driven focus move — key-driven
4268    /// Tab / Shift-Tab / Enter focus-advance, click-driven
4269    /// focus moves, etc. — so plugins never have to predict the
4270    /// host's focus rules to keep a local mirror in sync.
4271    ///
4272    /// No-op when the key isn't actually changing (avoids
4273    /// spurious events on every render that touches focus).
4274    pub(crate) fn set_panel_focus_and_notify(&mut self, panel_id: u64, new_key: String) {
4275        let old_key = self
4276            .widget_registry
4277            .focus_key(panel_id)
4278            .map(|s| s.to_string())
4279            .unwrap_or_default();
4280        if old_key == new_key {
4281            return;
4282        }
4283        self.widget_registry
4284            .set_focus_key(panel_id, new_key.clone());
4285        if self
4286            .plugin_manager
4287            .read()
4288            .unwrap()
4289            .has_hook_handlers("widget_event")
4290        {
4291            self.plugin_manager.read().unwrap().run_hook(
4292                "widget_event",
4293                fresh_core::hooks::HookArgs::WidgetEvent {
4294                    panel_id,
4295                    widget_key: new_key,
4296                    event_type: "focus".to_string(),
4297                    payload: serde_json::json!({ "previous": old_key }),
4298                },
4299            );
4300        }
4301    }
4302
4303    fn handle_widget_activate(&mut self, panel_id: u64) {
4304        // Fire `widget_event` based on the focused widget's kind.
4305        // Button → "activate"; Toggle → "toggle" (with the
4306        // computed-new payload); other kinds: no-op.
4307        let panel = match self.widget_registry.get(panel_id) {
4308            Some(p) => p,
4309            None => return,
4310        };
4311        let focus_key = panel.focus_key.clone();
4312        if focus_key.is_empty() {
4313            return;
4314        }
4315        let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
4316        let (event_type, payload) = match widget {
4317            // Disabled buttons don't fire activate. The renderer
4318            // already excludes them from the tab cycle and skips
4319            // their hit area, so the only way `focus_key` could
4320            // still point at a disabled button is a stale focus
4321            // from before the disable transition — drop the event
4322            // in that race.
4323            Some(fresh_core::api::WidgetSpec::Button { disabled: true, .. }) => return,
4324            Some(fresh_core::api::WidgetSpec::Button { .. }) => ("activate", serde_json::json!({})),
4325            Some(fresh_core::api::WidgetSpec::Toggle { checked, .. }) => {
4326                ("toggle", serde_json::json!({ "checked": !checked }))
4327            }
4328            _ => return,
4329        };
4330        if self
4331            .plugin_manager
4332            .read()
4333            .unwrap()
4334            .has_hook_handlers("widget_event")
4335        {
4336            self.plugin_manager.read().unwrap().run_hook(
4337                "widget_event",
4338                fresh_core::hooks::HookArgs::WidgetEvent {
4339                    panel_id,
4340                    widget_key: focus_key,
4341                    event_type: event_type.to_string(),
4342                    payload,
4343                },
4344            );
4345        }
4346    }
4347
4348    /// Fire a `widget_event { event_type: "activate", payload: {
4349    /// index, key } }` for the focused List, using its instance-state
4350    /// selection (or spec selection on first render). The plugin's
4351    /// activate handler does the actual user-visible thing — open
4352    /// the matched file, expand/collapse a tree node, etc.
4353    /// True when the focused widget on `panel_id` is a Text input
4354    /// whose host-managed completion popup is currently open
4355    /// (instance state has at least one candidate). Lets the
4356    /// smart-key dispatcher route Tab/Enter/Up/Down/Esc to the
4357    /// popup-specific paths before falling through to the
4358    /// widget's default key behaviour.
4359    fn focused_text_completions_open(&self, panel_id: u64) -> bool {
4360        let panel = match self.widget_registry.get(panel_id) {
4361            Some(p) => p,
4362            None => return false,
4363        };
4364        if panel.focus_key.is_empty() {
4365            return false;
4366        }
4367        matches!(
4368            panel.instance_states.get(&panel.focus_key),
4369            Some(crate::widgets::WidgetInstanceState::Text { completions, .. })
4370                if !completions.is_empty()
4371        )
4372    }
4373
4374    /// Move the selected-index cursor of the focused Text widget's
4375    /// completion popup by `delta` (Up = -1, Down = +1). Clamps
4376    /// at the ends rather than wrapping — Down past the last
4377    /// candidate stays on the last candidate, Up past the first
4378    /// stays on the first. Wraparound on a popup-style picker
4379    /// reads as "I scrolled past the bottom and now I'm at the
4380    /// top" which is jarring when the user is actively comparing
4381    /// items they expect to be in monotonic positions. No-op
4382    /// when the focused widget isn't a Text-with-open-
4383    /// completions.
4384    fn move_focused_text_completion_index(&mut self, panel_id: u64, delta: i32) {
4385        // First read the spec's visible-rows cap so we can pull
4386        // scroll back into view if the new selection lands above
4387        // the current scroll offset. (The renderer only does
4388        // forward-pull — it would otherwise fight the mouse-
4389        // wheel handler which deliberately diverges scroll from
4390        // selection.)
4391        let panel = match self.widget_registry.get(panel_id) {
4392            Some(p) => p,
4393            None => return,
4394        };
4395        let focus_key = panel.focus_key.clone();
4396        if focus_key.is_empty() {
4397            return;
4398        }
4399        let spec_visible_rows = match crate::widgets::find_widget_by_key(&panel.spec, &focus_key) {
4400            Some(fresh_core::api::WidgetSpec::Text {
4401                completions_visible_rows,
4402                ..
4403            }) => *completions_visible_rows,
4404            _ => 0,
4405        };
4406        let visible = if spec_visible_rows == 0 {
4407            5u32
4408        } else {
4409            spec_visible_rows
4410        };
4411        let panel = match self.widget_registry.get_mut(panel_id) {
4412            Some(p) => p,
4413            None => return,
4414        };
4415        if let Some(crate::widgets::WidgetInstanceState::Text {
4416            completions,
4417            completion_selected_index,
4418            completion_scroll_offset,
4419            ..
4420        }) = panel.instance_states.get_mut(&focus_key)
4421        {
4422            if completions.is_empty() {
4423                return;
4424            }
4425            let max = (completions.len() - 1) as i32;
4426            let cur = *completion_selected_index as i32;
4427            let next = (cur + delta).clamp(0, max);
4428            *completion_selected_index = next as usize;
4429            // Keyboard-driven selection move: if the new
4430            // selection sits above the current scroll window,
4431            // pull the scroll back so the selection stays
4432            // visible. Forward-pull is handled by the renderer.
4433            let next_u = next as u32;
4434            if next_u < *completion_scroll_offset {
4435                *completion_scroll_offset = next_u;
4436            } else if next_u >= *completion_scroll_offset + visible {
4437                *completion_scroll_offset = next_u + 1 - visible;
4438            }
4439        }
4440    }
4441
4442    /// Clear the focused Text widget's completion popup (close it)
4443    /// and fire a `completion_dismiss` event so the plugin can
4444    /// sync its own state (e.g. invalidate any in-flight fetch
4445    /// token, so a late-arriving result doesn't re-open the
4446    /// popup the user just closed). Used by Enter and Escape on
4447    /// a Text-with-open-completions.
4448    fn dismiss_focused_text_completions(&mut self, panel_id: u64) {
4449        let focus_key = {
4450            let panel = match self.widget_registry.get_mut(panel_id) {
4451                Some(p) => p,
4452                None => return,
4453            };
4454            let focus_key = panel.focus_key.clone();
4455            if focus_key.is_empty() {
4456                return;
4457            }
4458            if let Some(crate::widgets::WidgetInstanceState::Text {
4459                completions,
4460                completion_selected_index,
4461                ..
4462            }) = panel.instance_states.get_mut(&focus_key)
4463            {
4464                if completions.is_empty() {
4465                    return;
4466                }
4467                completions.clear();
4468                *completion_selected_index = 0;
4469            } else {
4470                return;
4471            }
4472            focus_key
4473        };
4474        if self
4475            .plugin_manager
4476            .read()
4477            .unwrap()
4478            .has_hook_handlers("widget_event")
4479        {
4480            self.plugin_manager.read().unwrap().run_hook(
4481                "widget_event",
4482                fresh_core::hooks::HookArgs::WidgetEvent {
4483                    panel_id,
4484                    widget_key: focus_key,
4485                    event_type: "completion_dismiss".into(),
4486                    payload: serde_json::json!({}),
4487                },
4488            );
4489        }
4490    }
4491
4492    /// Fire `completion_accept` on the focused Text widget's
4493    /// currently-selected candidate. Used by Tab on a Text-with-
4494    /// open-completions — the plugin's handler is expected to
4495    /// apply the accepted value to the field (typically via
4496    /// `WidgetMutation::SetValue`). The host does NOT close the
4497    /// popup automatically: directory-descent style flows (the
4498    /// orchestrator's Project Path acceptance of `/foo/` re-
4499    /// fetches children for the new path) want the popup to
4500    /// stay alive so the user can keep Tab-ing. Plugins that
4501    /// want a one-shot accept close the popup themselves with
4502    /// `setCompletions(key, [])`.
4503    fn fire_completion_accept(&mut self, panel_id: u64) {
4504        let (focus_key, value) = {
4505            let panel = match self.widget_registry.get(panel_id) {
4506                Some(p) => p,
4507                None => return,
4508            };
4509            let focus_key = panel.focus_key.clone();
4510            if focus_key.is_empty() {
4511                return;
4512            }
4513            match panel.instance_states.get(&focus_key) {
4514                Some(crate::widgets::WidgetInstanceState::Text {
4515                    completions,
4516                    completion_selected_index,
4517                    ..
4518                }) if !completions.is_empty() => {
4519                    let idx = (*completion_selected_index).min(completions.len() - 1);
4520                    (focus_key, completions[idx].value.clone())
4521                }
4522                _ => return,
4523            }
4524        };
4525        if self
4526            .plugin_manager
4527            .read()
4528            .unwrap()
4529            .has_hook_handlers("widget_event")
4530        {
4531            self.plugin_manager.read().unwrap().run_hook(
4532                "widget_event",
4533                fresh_core::hooks::HookArgs::WidgetEvent {
4534                    panel_id,
4535                    widget_key: focus_key,
4536                    event_type: "completion_accept".into(),
4537                    payload: serde_json::json!({ "value": value }),
4538                },
4539            );
4540        }
4541    }
4542
4543    fn fire_list_activate(&mut self, panel_id: u64, focus_key: &str) {
4544        let panel = match self.widget_registry.get(panel_id) {
4545            Some(p) => p,
4546            None => return,
4547        };
4548        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
4549        let (spec_sel, item_keys) = match widget {
4550            Some(fresh_core::api::WidgetSpec::List {
4551                selected_index,
4552                item_keys,
4553                ..
4554            }) => (*selected_index, item_keys.clone()),
4555            _ => return,
4556        };
4557        let sel = match panel.instance_states.get(focus_key) {
4558            Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
4559                *selected_index
4560            }
4561            _ => spec_sel,
4562        };
4563        if sel < 0 {
4564            return;
4565        }
4566        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
4567        if self
4568            .plugin_manager
4569            .read()
4570            .unwrap()
4571            .has_hook_handlers("widget_event")
4572        {
4573            self.plugin_manager.read().unwrap().run_hook(
4574                "widget_event",
4575                fresh_core::hooks::HookArgs::WidgetEvent {
4576                    panel_id,
4577                    widget_key: focus_key.to_string(),
4578                    event_type: "activate".into(),
4579                    payload: serde_json::json!({
4580                        "index": sel,
4581                        "key": item_key,
4582                    }),
4583                },
4584            );
4585        }
4586    }
4587
4588    fn handle_widget_select_move(&mut self, panel_id: u64, delta: i32) {
4589        let focus_key = match self.widget_registry.get(panel_id) {
4590            Some(p) => p.focus_key.clone(),
4591            None => return,
4592        };
4593        if focus_key.is_empty() {
4594            return;
4595        }
4596        self.handle_widget_select_move_for_key(panel_id, &focus_key, delta);
4597    }
4598
4599    /// Set a `List` widget's selected index to an absolute item index,
4600    /// preserving its scroll offset, and repaint. Used by the click
4601    /// path: a row click only produces a `select` hit and — unlike
4602    /// keyboard nav via [`handle_widget_select_move_for_key`] — does
4603    /// not move the host-owned selection. Without this the highlight
4604    /// would not follow a click and a subsequent Up/Down would resume
4605    /// from the stale index.
4606    pub(super) fn set_widget_list_selected_index(
4607        &mut self,
4608        panel_id: u64,
4609        widget_key: &str,
4610        index: i32,
4611    ) {
4612        if let Some(panel) = self.widget_registry.get_mut(panel_id) {
4613            let prev_scroll = match panel.instance_states.get(widget_key) {
4614                Some(crate::widgets::WidgetInstanceState::List { scroll_offset, .. }) => {
4615                    *scroll_offset
4616                }
4617                _ => 0,
4618            };
4619            panel.instance_states.insert(
4620                widget_key.to_string(),
4621                crate::widgets::WidgetInstanceState::List {
4622                    scroll_offset: prev_scroll,
4623                    selected_index: index,
4624                },
4625            );
4626        }
4627        self.rerender_widget_panel(panel_id);
4628    }
4629
4630    /// Same as [`handle_widget_select_move`] but targets an explicit
4631    /// `List` widget key instead of the panel's focused widget. Used
4632    /// by the picker-style smart-key dispatch — `Up`/`Down` on a
4633    /// focused filter input route to the first scrollable widget in
4634    /// the panel without changing focus.
4635    fn handle_widget_select_move_for_key(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4636        let panel = match self.widget_registry.get(panel_id) {
4637            Some(p) => p,
4638            None => return,
4639        };
4640        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4641        let (spec_sel, total, item_keys) = match widget {
4642            Some(fresh_core::api::WidgetSpec::List {
4643                selected_index,
4644                items,
4645                item_keys,
4646                ..
4647            }) => (*selected_index, items.len() as i32, item_keys.clone()),
4648            _ => return,
4649        };
4650        if total == 0 {
4651            return;
4652        }
4653        let cur_sel = match panel.instance_states.get(widget_key) {
4654            Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
4655                *selected_index
4656            }
4657            _ => spec_sel,
4658        };
4659        let raw = if cur_sel < 0 { 0 } else { cur_sel + delta };
4660        let new_sel = raw.clamp(0, total - 1);
4661        let new_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
4662        if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4663            let cur_scroll = match panel_mut.instance_states.get(widget_key) {
4664                Some(crate::widgets::WidgetInstanceState::List { scroll_offset, .. }) => {
4665                    *scroll_offset
4666                }
4667                _ => 0,
4668            };
4669            panel_mut.instance_states.insert(
4670                widget_key.to_string(),
4671                crate::widgets::WidgetInstanceState::List {
4672                    scroll_offset: cur_scroll,
4673                    selected_index: new_sel,
4674                },
4675            );
4676        }
4677        self.rerender_widget_panel(panel_id);
4678        if self
4679            .plugin_manager
4680            .read()
4681            .unwrap()
4682            .has_hook_handlers("widget_event")
4683        {
4684            self.plugin_manager.read().unwrap().run_hook(
4685                "widget_event",
4686                fresh_core::hooks::HookArgs::WidgetEvent {
4687                    panel_id,
4688                    widget_key: widget_key.to_string(),
4689                    event_type: "select".into(),
4690                    payload: serde_json::json!({ "index": new_sel, "key": new_key }),
4691                },
4692            );
4693        }
4694    }
4695
4696    /// Move the focused Tree's selection up/down, skipping
4697    /// descendants of collapsed nodes. Selection is the *absolute*
4698    /// `nodes` index; we walk the visible-flat order to find the
4699    /// neighbour. Mirrors the List handler shape but tree-aware.
4700    fn handle_widget_tree_select_move(&mut self, panel_id: u64, delta: i32) {
4701        let focus_key = match self.widget_registry.get(panel_id) {
4702            Some(p) => p.focus_key.clone(),
4703            None => return,
4704        };
4705        if focus_key.is_empty() {
4706            return;
4707        }
4708        self.handle_widget_tree_select_move_for_key(panel_id, &focus_key, delta);
4709    }
4710
4711    /// Tree counterpart of [`handle_widget_select_move_for_key`].
4712    fn handle_widget_tree_select_move_for_key(
4713        &mut self,
4714        panel_id: u64,
4715        widget_key: &str,
4716        delta: i32,
4717    ) {
4718        let panel = match self.widget_registry.get(panel_id) {
4719            Some(p) => p,
4720            None => return,
4721        };
4722        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4723        let (spec_sel, nodes, item_keys) = match widget {
4724            Some(fresh_core::api::WidgetSpec::Tree {
4725                selected_index,
4726                nodes,
4727                item_keys,
4728                ..
4729            }) => (*selected_index, nodes.clone(), item_keys.clone()),
4730            _ => return,
4731        };
4732        if nodes.is_empty() {
4733            return;
4734        }
4735        let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
4736            Some(crate::widgets::WidgetInstanceState::Tree {
4737                selected_index,
4738                scroll_offset,
4739                expanded_keys,
4740            }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
4741            _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
4742        };
4743        let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
4744        if visible_indices.is_empty() {
4745            return;
4746        }
4747        let cur_pos = if cur_sel < 0 {
4748            if delta > 0 {
4749                -1
4750            } else {
4751                visible_indices.len() as i32
4752            }
4753        } else {
4754            visible_indices
4755                .iter()
4756                .position(|&v| v as i32 == cur_sel)
4757                .map(|p| p as i32)
4758                .unwrap_or(-1)
4759        };
4760        let new_pos = (cur_pos + delta).clamp(0, (visible_indices.len() as i32) - 1);
4761        let new_abs = visible_indices[new_pos as usize];
4762        let new_key = item_keys.get(new_abs).cloned().unwrap_or_default();
4763        if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4764            panel_mut.instance_states.insert(
4765                widget_key.to_string(),
4766                crate::widgets::WidgetInstanceState::Tree {
4767                    scroll_offset: cur_scroll,
4768                    selected_index: new_abs as i32,
4769                    expanded_keys: expanded,
4770                },
4771            );
4772        }
4773        self.rerender_widget_panel(panel_id);
4774        if self
4775            .plugin_manager
4776            .read()
4777            .unwrap()
4778            .has_hook_handlers("widget_event")
4779        {
4780            self.plugin_manager.read().unwrap().run_hook(
4781                "widget_event",
4782                fresh_core::hooks::HookArgs::WidgetEvent {
4783                    panel_id,
4784                    widget_key: widget_key.to_string(),
4785                    event_type: "select".into(),
4786                    payload: serde_json::json!({ "index": new_abs as i64, "key": new_key }),
4787                },
4788            );
4789        }
4790    }
4791
4792    /// Mouse-wheel scroll over a widget panel buffer. Finds the
4793    /// first `Tree`/`List` in any panel rendering into `buffer_id`
4794    /// and shifts its viewport by `delta` rows. Drags the selection
4795    /// to stay inside the new visible window so the renderer's
4796    /// auto-scroll doesn't snap the offset back. No focus change,
4797    /// no `widget_event` fires — wheel is viewport navigation, not
4798    /// selection.
4799    ///
4800    /// Returns `true` if any panel consumed the scroll.
4801    pub(super) fn handle_widget_panel_wheel(
4802        &mut self,
4803        buffer_id: crate::model::event::BufferId,
4804        delta: i32,
4805    ) -> bool {
4806        let panels = self.widget_registry.panels_for_buffer(buffer_id);
4807        let mut consumed = false;
4808        for panel_id in panels {
4809            // First chance: a focused Text widget with an open
4810            // completion popup absorbs the wheel — scrolling the
4811            // candidate list when the popup is what the user is
4812            // pointing at takes priority over scrolling a
4813            // sibling List/Tree elsewhere on the panel.
4814            if self.focused_text_completions_open(panel_id) {
4815                self.scroll_focused_text_completions(panel_id, delta);
4816                // The renderer reads `completion_scroll_offset`
4817                // out of the Text widget's instance state on
4818                // each paint, so flushing a rerender here is
4819                // what actually puts the new scroll on screen
4820                // — without this, the cached overlay rows on
4821                // the floating panel stay pinned to the old
4822                // offset until the user's next keystroke
4823                // happens to re-render for some other reason.
4824                self.rerender_widget_panel(panel_id);
4825                consumed = true;
4826                continue;
4827            }
4828            let spec = match self.widget_registry.get(panel_id) {
4829                Some(p) => p.spec.clone(),
4830                None => continue,
4831            };
4832            let Some(widget_key) = find_scrollable_widget_key(&spec) else {
4833                continue;
4834            };
4835            let widget = crate::widgets::find_widget_by_key(&spec, &widget_key);
4836            match widget {
4837                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4838                    self.handle_widget_tree_wheel(panel_id, &widget_key, delta);
4839                    consumed = true;
4840                }
4841                Some(fresh_core::api::WidgetSpec::List { .. }) => {
4842                    self.handle_widget_list_wheel(panel_id, &widget_key, delta);
4843                    consumed = true;
4844                }
4845                _ => {}
4846            }
4847        }
4848        consumed
4849    }
4850
4851    /// Shift the focused Text widget's completion popup scroll
4852    /// offset by `delta` rows. The renderer reads the visible-
4853    /// rows cap from the Text spec; we approximate it here as
4854    /// "5 if zero / unset" to mirror the renderer's default —
4855    /// the cap matters for clamping the max scroll so the
4856    /// thumb doesn't drift past the end.
4857    fn scroll_focused_text_completions(&mut self, panel_id: u64, delta: i32) {
4858        let panel = match self.widget_registry.get(panel_id) {
4859            Some(p) => p,
4860            None => return,
4861        };
4862        let focus_key = panel.focus_key.clone();
4863        if focus_key.is_empty() {
4864            return;
4865        }
4866        let spec_visible_rows = match crate::widgets::find_widget_by_key(&panel.spec, &focus_key) {
4867            Some(fresh_core::api::WidgetSpec::Text {
4868                completions_visible_rows,
4869                ..
4870            }) => *completions_visible_rows,
4871            _ => 0,
4872        };
4873        let visible = if spec_visible_rows == 0 {
4874            5u32
4875        } else {
4876            spec_visible_rows
4877        };
4878        let panel = match self.widget_registry.get_mut(panel_id) {
4879            Some(p) => p,
4880            None => return,
4881        };
4882        if let Some(crate::widgets::WidgetInstanceState::Text {
4883            completions,
4884            completion_scroll_offset,
4885            ..
4886        }) = panel.instance_states.get_mut(&focus_key)
4887        {
4888            if completions.is_empty() {
4889                return;
4890            }
4891            let total = completions.len() as u32;
4892            let max_scroll = total.saturating_sub(visible.min(total));
4893            let next = (*completion_scroll_offset as i32 + delta).clamp(0, max_scroll as i32);
4894            *completion_scroll_offset = next as u32;
4895        }
4896    }
4897
4898    /// Shift a Tree's `scroll_offset` by `delta` rows. If the
4899    /// selection would fall outside the new viewport, drag it to
4900    /// the edge so the renderer's keep-selection-visible logic
4901    /// doesn't snap the offset back.
4902    fn handle_widget_tree_wheel(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4903        let panel = match self.widget_registry.get(panel_id) {
4904            Some(p) => p,
4905            None => return,
4906        };
4907        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4908        let (visible_rows, nodes, item_keys) = match widget {
4909            Some(fresh_core::api::WidgetSpec::Tree {
4910                visible_rows,
4911                nodes,
4912                item_keys,
4913                ..
4914            }) => (*visible_rows, nodes.clone(), item_keys.clone()),
4915            _ => return,
4916        };
4917        if nodes.is_empty() {
4918            return;
4919        }
4920        let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
4921            Some(crate::widgets::WidgetInstanceState::Tree {
4922                selected_index,
4923                scroll_offset,
4924                expanded_keys,
4925            }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
4926            _ => (-1, 0, std::collections::HashSet::<String>::new()),
4927        };
4928        let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
4929        if visible_indices.is_empty() {
4930            return;
4931        }
4932        let visible = visible_rows.max(1);
4933        let total_visible = visible_indices.len() as u32;
4934        let max_scroll = total_visible.saturating_sub(visible);
4935        let new_scroll = (cur_scroll as i32 + delta).clamp(0, max_scroll as i32) as u32;
4936        if new_scroll == cur_scroll {
4937            return;
4938        }
4939        // Drag selection to stay inside the new viewport.
4940        let cur_pos: Option<u32> = if cur_sel >= 0 {
4941            visible_indices
4942                .iter()
4943                .position(|&v| v as i32 == cur_sel)
4944                .map(|p| p as u32)
4945        } else {
4946            None
4947        };
4948        let new_sel_abs = match cur_pos {
4949            Some(pos) if pos < new_scroll => visible_indices[new_scroll as usize] as i32,
4950            Some(pos) if pos >= new_scroll + visible => {
4951                visible_indices[(new_scroll + visible - 1) as usize] as i32
4952            }
4953            _ => cur_sel,
4954        };
4955        if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4956            panel_mut.instance_states.insert(
4957                widget_key.to_string(),
4958                crate::widgets::WidgetInstanceState::Tree {
4959                    scroll_offset: new_scroll,
4960                    selected_index: new_sel_abs,
4961                    expanded_keys: expanded,
4962                },
4963            );
4964        }
4965        self.rerender_widget_panel(panel_id);
4966    }
4967
4968    /// List counterpart of `handle_widget_tree_wheel`.
4969    fn handle_widget_list_wheel(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4970        let panel = match self.widget_registry.get(panel_id) {
4971            Some(p) => p,
4972            None => return,
4973        };
4974        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4975        let (visible_rows, total) = match widget {
4976            Some(fresh_core::api::WidgetSpec::List {
4977                visible_rows,
4978                items,
4979                ..
4980            }) => (*visible_rows, items.len() as u32),
4981            _ => return,
4982        };
4983        if total == 0 {
4984            return;
4985        }
4986        let (cur_sel, cur_scroll) = match panel.instance_states.get(widget_key) {
4987            Some(crate::widgets::WidgetInstanceState::List {
4988                selected_index,
4989                scroll_offset,
4990            }) => (*selected_index, *scroll_offset),
4991            _ => (-1, 0),
4992        };
4993        let visible = visible_rows.max(1);
4994        let max_scroll = total.saturating_sub(visible);
4995        let new_scroll = (cur_scroll as i32 + delta).clamp(0, max_scroll as i32) as u32;
4996        if new_scroll == cur_scroll {
4997            return;
4998        }
4999        let new_sel = if cur_sel < 0 {
5000            cur_sel
5001        } else if (cur_sel as u32) < new_scroll {
5002            new_scroll as i32
5003        } else if (cur_sel as u32) >= new_scroll + visible {
5004            (new_scroll + visible - 1) as i32
5005        } else {
5006            cur_sel
5007        };
5008        if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
5009            panel_mut.instance_states.insert(
5010                widget_key.to_string(),
5011                crate::widgets::WidgetInstanceState::List {
5012                    scroll_offset: new_scroll,
5013                    selected_index: new_sel,
5014                },
5015            );
5016        }
5017        self.rerender_widget_panel(panel_id);
5018    }
5019
5020    /// Right/Left arrow on a focused Tree.
5021    ///
5022    /// * Right: if the selected node has children and is collapsed,
5023    ///   expand it. Else no-op.
5024    /// * Left: if the selected node has children and is expanded,
5025    ///   collapse it. Else move selection up to the parent.
5026    ///
5027    /// Both update host instance state, re-render, and (when a
5028    /// change happened) fire `widget_event { event_type: "expand" }`.
5029    fn handle_widget_tree_lateral(&mut self, panel_id: u64, is_right: bool) {
5030        let panel = match self.widget_registry.get(panel_id) {
5031            Some(p) => p,
5032            None => return,
5033        };
5034        let focus_key = panel.focus_key.clone();
5035        if focus_key.is_empty() {
5036            return;
5037        }
5038        let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
5039        let (spec_sel, nodes, item_keys) = match widget {
5040            Some(fresh_core::api::WidgetSpec::Tree {
5041                selected_index,
5042                nodes,
5043                item_keys,
5044                ..
5045            }) => (*selected_index, nodes.clone(), item_keys.clone()),
5046            _ => return,
5047        };
5048        if nodes.is_empty() {
5049            return;
5050        }
5051        let (cur_sel, cur_scroll, mut expanded) = match panel.instance_states.get(&focus_key) {
5052            Some(crate::widgets::WidgetInstanceState::Tree {
5053                selected_index,
5054                scroll_offset,
5055                expanded_keys,
5056            }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
5057            _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
5058        };
5059        if cur_sel < 0 {
5060            return;
5061        }
5062        let sel_idx = cur_sel as usize;
5063        let node = match nodes.get(sel_idx) {
5064            Some(n) => n,
5065            None => return,
5066        };
5067        let key = item_keys.get(sel_idx).cloned().unwrap_or_default();
5068        let was_expanded = !key.is_empty() && expanded.contains(&key);
5069
5070        let mut new_sel = cur_sel;
5071        let mut expansion_changed: Option<bool> = None; // Some(new_state)
5072        if is_right {
5073            if node.has_children && !was_expanded && !key.is_empty() {
5074                expanded.insert(key.clone());
5075                expansion_changed = Some(true);
5076            }
5077        } else if node.has_children && was_expanded && !key.is_empty() {
5078            expanded.remove(&key);
5079            expansion_changed = Some(false);
5080        } else if let Some(parent_idx) = crate::widgets::tree_parent_index(&nodes, sel_idx) {
5081            new_sel = parent_idx as i32;
5082        }
5083        // No change → bail (don't fire spurious select/expand).
5084        if expansion_changed.is_none() && new_sel == cur_sel {
5085            return;
5086        }
5087        let final_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
5088        if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
5089            panel_mut.instance_states.insert(
5090                focus_key.clone(),
5091                crate::widgets::WidgetInstanceState::Tree {
5092                    scroll_offset: cur_scroll,
5093                    selected_index: new_sel,
5094                    expanded_keys: expanded,
5095                },
5096            );
5097        }
5098        self.rerender_widget_panel(panel_id);
5099        if self
5100            .plugin_manager
5101            .read()
5102            .unwrap()
5103            .has_hook_handlers("widget_event")
5104        {
5105            if let Some(now_expanded) = expansion_changed {
5106                self.plugin_manager.read().unwrap().run_hook(
5107                    "widget_event",
5108                    fresh_core::hooks::HookArgs::WidgetEvent {
5109                        panel_id,
5110                        widget_key: focus_key.clone(),
5111                        event_type: "expand".into(),
5112                        payload: serde_json::json!({
5113                            "index": cur_sel as i64,
5114                            "key": key,
5115                            "expanded": now_expanded,
5116                        }),
5117                    },
5118                );
5119            } else if new_sel != cur_sel {
5120                self.plugin_manager.read().unwrap().run_hook(
5121                    "widget_event",
5122                    fresh_core::hooks::HookArgs::WidgetEvent {
5123                        panel_id,
5124                        widget_key: focus_key,
5125                        event_type: "select".into(),
5126                        payload: serde_json::json!({
5127                            "index": new_sel as i64,
5128                            "key": final_key,
5129                        }),
5130                    },
5131                );
5132            }
5133        }
5134    }
5135
5136    /// Toggle a Tree node's expansion state, re-render, and fire
5137    /// `widget_event { event_type: "expand" }`. Used by the click
5138    /// handler when the user clicks the disclosure column.
5139    pub(crate) fn handle_widget_tree_expand_toggle(
5140        &mut self,
5141        panel_id: u64,
5142        widget_key: &str,
5143        item_key: &str,
5144    ) {
5145        if widget_key.is_empty() || item_key.is_empty() {
5146            return;
5147        }
5148        let now_expanded = {
5149            let panel = match self.widget_registry.get_mut(panel_id) {
5150                Some(p) => p,
5151                None => return,
5152            };
5153            let (cur_scroll, cur_sel, mut expanded) = match panel.instance_states.get(widget_key) {
5154                Some(crate::widgets::WidgetInstanceState::Tree {
5155                    scroll_offset,
5156                    selected_index,
5157                    expanded_keys,
5158                }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
5159                _ => (0u32, -1i32, std::collections::HashSet::<String>::new()),
5160            };
5161            let next = if expanded.contains(item_key) {
5162                expanded.remove(item_key);
5163                false
5164            } else {
5165                expanded.insert(item_key.to_string());
5166                true
5167            };
5168            panel.instance_states.insert(
5169                widget_key.to_string(),
5170                crate::widgets::WidgetInstanceState::Tree {
5171                    scroll_offset: cur_scroll,
5172                    selected_index: cur_sel,
5173                    expanded_keys: expanded,
5174                },
5175            );
5176            next
5177        };
5178        self.rerender_widget_panel(panel_id);
5179        if self
5180            .plugin_manager
5181            .read()
5182            .unwrap()
5183            .has_hook_handlers("widget_event")
5184        {
5185            self.plugin_manager.read().unwrap().run_hook(
5186                "widget_event",
5187                fresh_core::hooks::HookArgs::WidgetEvent {
5188                    panel_id,
5189                    widget_key: widget_key.to_string(),
5190                    event_type: "expand".into(),
5191                    payload: serde_json::json!({
5192                        "key": item_key,
5193                        "expanded": now_expanded,
5194                    }),
5195                },
5196            );
5197        }
5198    }
5199
5200    /// Fire `widget_event { event_type: "activate" }` for the focused
5201    /// Tree's currently-selected node. Mirrors `fire_list_activate`
5202    /// — the plugin's handler decides what "activate" means
5203    /// (open the file, run an action, etc.).
5204    /// If the focused Tree row is checkable (parent tree has
5205    /// `checkable: true` *and* the row's `checked` is `Some(_)`),
5206    /// fire `widget_event { event_type: "toggle" }` with the
5207    /// inverted value and return `true`. Otherwise return `false`
5208    /// so the caller falls back to `activate`.
5209    ///
5210    /// Mirrors what a click on the row's `[v]`/`[ ]` glyph would
5211    /// do — Space is the conventional checkbox key, so on a
5212    /// checkable tree Space toggles instead of activating.
5213    fn fire_tree_toggle_if_checkable(&mut self, panel_id: u64, focus_key: &str) -> bool {
5214        let panel = match self.widget_registry.get(panel_id) {
5215            Some(p) => p,
5216            None => return false,
5217        };
5218        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
5219        let (spec_sel, nodes, item_keys, checkable) = match widget {
5220            Some(fresh_core::api::WidgetSpec::Tree {
5221                selected_index,
5222                nodes,
5223                item_keys,
5224                checkable,
5225                ..
5226            }) => (*selected_index, nodes, item_keys.clone(), *checkable),
5227            _ => return false,
5228        };
5229        if !checkable {
5230            return false;
5231        }
5232        let sel = match panel.instance_states.get(focus_key) {
5233            Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
5234                *selected_index
5235            }
5236            _ => spec_sel,
5237        };
5238        if sel < 0 {
5239            return false;
5240        }
5241        let cur_checked = match nodes.get(sel as usize).and_then(|n| n.checked) {
5242            Some(b) => b,
5243            None => return false, // No checkbox glyph on this row — let activate fire.
5244        };
5245        let new_checked = !cur_checked;
5246        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
5247        if self
5248            .plugin_manager
5249            .read()
5250            .unwrap()
5251            .has_hook_handlers("widget_event")
5252        {
5253            self.plugin_manager.read().unwrap().run_hook(
5254                "widget_event",
5255                fresh_core::hooks::HookArgs::WidgetEvent {
5256                    panel_id,
5257                    widget_key: focus_key.to_string(),
5258                    event_type: "toggle".into(),
5259                    payload: serde_json::json!({
5260                        "index": sel,
5261                        "key": item_key,
5262                        "checked": new_checked,
5263                    }),
5264                },
5265            );
5266        }
5267        true
5268    }
5269
5270    fn fire_tree_activate(&mut self, panel_id: u64, focus_key: &str) {
5271        let panel = match self.widget_registry.get(panel_id) {
5272            Some(p) => p,
5273            None => return,
5274        };
5275        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
5276        let (spec_sel, item_keys) = match widget {
5277            Some(fresh_core::api::WidgetSpec::Tree {
5278                selected_index,
5279                item_keys,
5280                ..
5281            }) => (*selected_index, item_keys.clone()),
5282            _ => return,
5283        };
5284        let sel = match panel.instance_states.get(focus_key) {
5285            Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
5286                *selected_index
5287            }
5288            _ => spec_sel,
5289        };
5290        if sel < 0 {
5291            return;
5292        }
5293        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
5294        if self
5295            .plugin_manager
5296            .read()
5297            .unwrap()
5298            .has_hook_handlers("widget_event")
5299        {
5300            self.plugin_manager.read().unwrap().run_hook(
5301                "widget_event",
5302                fresh_core::hooks::HookArgs::WidgetEvent {
5303                    panel_id,
5304                    widget_key: focus_key.to_string(),
5305                    event_type: "activate".into(),
5306                    payload: serde_json::json!({
5307                        "index": sel,
5308                        "key": item_key,
5309                    }),
5310                },
5311            );
5312        }
5313    }
5314
5315    /// Walk every panel rendering into `buffer_id` and return the
5316    /// first one whose currently-focused widget is a `Text`.
5317    /// Returns `None` when no such panel exists (e.g. when the
5318    /// buffer is a regular text buffer, or the panel has focus on
5319    /// a `Button` / `List` / etc.).
5320    ///
5321    /// This is the universal hook the clipboard ops use to route
5322    /// Paste / Copy / Cut / Select-All to a focused widget text
5323    /// field instead of the underlying buffer. Same idea as the
5324    /// existing Prompt and FileExplorer branches in the clipboard
5325    /// path, generalised: any plugin-mounted Text widget that has
5326    /// focus wins over the underlying buffer.
5327    pub(super) fn focused_text_widget_panel_for_buffer(
5328        &self,
5329        buffer_id: crate::model::event::BufferId,
5330    ) -> Option<u64> {
5331        for panel_id in self.widget_registry.panels_for_buffer(buffer_id) {
5332            let panel = self.widget_registry.get(panel_id)?;
5333            if panel.focus_key.is_empty() {
5334                continue;
5335            }
5336            let widget = crate::widgets::find_widget_by_key(&panel.spec, &panel.focus_key);
5337            if matches!(widget, Some(fresh_core::api::WidgetSpec::Text { .. })) {
5338                return Some(panel_id);
5339            }
5340        }
5341        None
5342    }
5343
5344    /// Read the currently-selected text from the focused `Text`
5345    /// widget on the given panel, or `None` when nothing is
5346    /// selected (no anchor, or anchor == cursor). Used by the
5347    /// host-side Copy / Cut routing path.
5348    pub(super) fn focused_widget_selected_text(&self, panel_id: u64) -> Option<String> {
5349        let panel = self.widget_registry.get(panel_id)?;
5350        if panel.focus_key.is_empty() {
5351            return None;
5352        }
5353        match panel.instance_states.get(&panel.focus_key) {
5354            Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
5355                editor.selected_text()
5356            }
5357            _ => None,
5358        }
5359    }
5360
5361    /// Select-all in the focused widget Text. Returns true when
5362    /// applied (focus was a Text widget). The op fires a `change`
5363    /// event only if the selection range actually changed; an
5364    /// already-fully-selected widget is a no-op.
5365    pub(super) fn handle_widget_select_all(&mut self, panel_id: u64) -> bool {
5366        // SelectAll moves the cursor to end-of-value and sets anchor
5367        // at start — `with_focused_text_editor` will skip re-render
5368        // when nothing changed, which is fine.
5369        self.with_focused_text_editor(panel_id, |editor| editor.select_all())
5370    }
5371
5372    /// Copy the focused widget Text's current selection to the
5373    /// internal clipboard. Returns true when copy ran (even when
5374    /// the selection was empty — the action is consumed either way
5375    /// so it doesn't fall through to the buffer's copy path).
5376    pub(super) fn handle_widget_copy(&mut self, panel_id: u64) -> bool {
5377        if self.widget_registry.get(panel_id).is_none() {
5378            return false;
5379        }
5380        if let Some(text) = self.focused_widget_selected_text(panel_id) {
5381            self.clipboard.copy(text);
5382        }
5383        true
5384    }
5385
5386    /// Cut the focused widget Text's current selection — copy then
5387    /// delete. With no selection, this is a no-op consume.
5388    pub(super) fn handle_widget_cut(&mut self, panel_id: u64) -> bool {
5389        if self.widget_registry.get(panel_id).is_none() {
5390            return false;
5391        }
5392        if let Some(text) = self.focused_widget_selected_text(panel_id) {
5393            self.clipboard.copy(text);
5394            self.with_focused_text_editor(panel_id, |editor| {
5395                editor.delete_selection();
5396            });
5397        }
5398        true
5399    }
5400
5401    /// Insert `text` at the focused widget Text's cursor (replacing
5402    /// any active selection). Used by the host-side Paste routing
5403    /// path; `text` is already line-ending-normalised by the
5404    /// caller (CRLF / CR → LF). `TextEdit::insert_str` strips
5405    /// embedded newlines when the editor is single-line.
5406    pub(super) fn handle_widget_insert_str(&mut self, panel_id: u64, text: &str) -> bool {
5407        if self.widget_registry.get(panel_id).is_none() {
5408            return false;
5409        }
5410        let owned = text.to_string();
5411        self.with_focused_text_editor(panel_id, move |editor| {
5412            editor.insert_str(&owned);
5413        });
5414        true
5415    }
5416
5417    /// Ensure `panel.instance_states[focus_key]` is a seeded
5418    /// `Text { editor, .. }` for the focused widget. If instance
5419    /// state already has the entry, no-op. If not, seeds from the
5420    /// spec's `value` / `cursor_byte` / `rows`. Returns true on
5421    /// success (focus is a Text widget that's now in instance state),
5422    /// false otherwise.
5423    fn ensure_focused_text_seeded(&mut self, panel_id: u64, focus_key: &str) -> bool {
5424        let panel = match self.widget_registry.get_mut(panel_id) {
5425            Some(p) => p,
5426            None => return false,
5427        };
5428        if matches!(
5429            panel.instance_states.get(focus_key),
5430            Some(crate::widgets::WidgetInstanceState::Text { .. })
5431        ) {
5432            return true;
5433        }
5434        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
5435        let (value, cursor_byte, multiline) = match widget {
5436            Some(fresh_core::api::WidgetSpec::Text {
5437                value,
5438                cursor_byte,
5439                rows,
5440                ..
5441            }) => (value.clone(), *cursor_byte, *rows > 1),
5442            _ => return false,
5443        };
5444        let mut editor = if multiline {
5445            crate::primitives::text_edit::TextEdit::with_text(&value)
5446        } else {
5447            crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
5448        };
5449        let seed = if cursor_byte < 0 {
5450            value.len()
5451        } else {
5452            (cursor_byte as usize).min(value.len())
5453        };
5454        editor.set_cursor_from_flat(seed);
5455        panel.instance_states.insert(
5456            focus_key.to_string(),
5457            crate::widgets::WidgetInstanceState::Text {
5458                editor,
5459                scroll: 0,
5460                completions: Vec::new(),
5461                completion_selected_index: 0,
5462                completion_scroll_offset: 0,
5463            },
5464        );
5465        true
5466    }
5467
5468    /// Apply a mutating operation to the focused `Text` widget's
5469    /// `TextEdit`. Handles seeding the editor from the spec on first
5470    /// touch, no-op detection (skips rerender + change event), and
5471    /// firing the `widget_event` "change" hook with the post-state.
5472    ///
5473    /// Returns true when the op ran *and* produced a visible change.
5474    pub(super) fn with_focused_text_editor<F>(&mut self, panel_id: u64, op: F) -> bool
5475    where
5476        F: FnOnce(&mut crate::primitives::text_edit::TextEdit),
5477    {
5478        let focus_key = match self.widget_registry.get(panel_id) {
5479            Some(p) if !p.focus_key.is_empty() => p.focus_key.clone(),
5480            _ => return false,
5481        };
5482        if !self.ensure_focused_text_seeded(panel_id, &focus_key) {
5483            return false;
5484        }
5485        let (before_value, before_cursor) = {
5486            let panel = self.widget_registry.get(panel_id).unwrap();
5487            match panel.instance_states.get(&focus_key) {
5488                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
5489                    (editor.value(), editor.flat_cursor_byte())
5490                }
5491                _ => return false,
5492            }
5493        };
5494        {
5495            let panel = self.widget_registry.get_mut(panel_id).unwrap();
5496            match panel.instance_states.get_mut(&focus_key) {
5497                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => op(editor),
5498                _ => return false,
5499            }
5500        }
5501        let (after_value, after_cursor) = {
5502            let panel = self.widget_registry.get(panel_id).unwrap();
5503            match panel.instance_states.get(&focus_key) {
5504                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
5505                    (editor.value(), editor.flat_cursor_byte())
5506                }
5507                _ => return false,
5508            }
5509        };
5510        if after_value == before_value && after_cursor == before_cursor {
5511            return false;
5512        }
5513        self.rerender_widget_panel(panel_id);
5514        if self
5515            .plugin_manager
5516            .read()
5517            .unwrap()
5518            .has_hook_handlers("widget_event")
5519        {
5520            self.plugin_manager.read().unwrap().run_hook(
5521                "widget_event",
5522                fresh_core::hooks::HookArgs::WidgetEvent {
5523                    panel_id,
5524                    widget_key: focus_key.clone(),
5525                    event_type: "change".into(),
5526                    payload: serde_json::json!({
5527                        "value": after_value,
5528                        "cursorByte": after_cursor as i64,
5529                    }),
5530                },
5531            );
5532        }
5533        true
5534    }
5535
5536    /// Apply a non-printable editing key to the focused text widget
5537    /// by dispatching to the corresponding `TextEdit` method. The
5538    /// single/multi-line discriminator is carried by `TextEdit`'s
5539    /// `multiline` field, so the same set of methods serves both
5540    /// kinds — single-line just no-ops on Up/Down/Enter.
5541    fn handle_widget_text_key(&mut self, panel_id: u64, key: &str) {
5542        self.with_focused_text_editor(panel_id, |editor| match key {
5543            "Backspace" => editor.backspace(),
5544            "Delete" => editor.delete(),
5545            "Left" => editor.move_left(),
5546            "Right" => editor.move_right(),
5547            "Up" => editor.move_up(),
5548            "Down" => editor.move_down(),
5549            "Home" => editor.move_home(),
5550            "End" => editor.move_end(),
5551            "Enter" => editor.insert_char('\n'),
5552            _ => { /* unknown key — no-op */ }
5553        });
5554    }
5555
5556    /// Insert printable / IME-committed text at the focused text
5557    /// widget's cursor. Same path for single-line and multi-line —
5558    /// `TextEdit::insert_str` strips `\n` automatically when the
5559    /// editor was constructed single-line. `text` may be a single
5560    /// codepoint, a grapheme cluster, or a multi-codepoint IME
5561    /// commit; `insert_str` handles each identically.
5562    fn handle_widget_text_char(&mut self, panel_id: u64, text: &str) {
5563        if text.is_empty() {
5564            return;
5565        }
5566        let text = text.to_string();
5567        self.with_focused_text_editor(panel_id, move |editor| {
5568            editor.insert_str(&text);
5569        });
5570    }
5571
5572    fn handle_unmount_widget_panel(&mut self, panel_id: u64) {
5573        match self.widget_registry.unmount(panel_id) {
5574            Some(buffer_id) => {
5575                tracing::debug!(
5576                    "Unmounted widget panel {} (was rendering into {:?})",
5577                    panel_id,
5578                    buffer_id
5579                );
5580                // Buffer lifetime is owned by the plugin (it created the
5581                // virtual buffer before mounting). The plugin is
5582                // responsible for closing/clearing it; we only forget our
5583                // panel state.
5584            }
5585            None => {
5586                tracing::debug!("UnmountWidgetPanel for unknown panel {} ignored", panel_id);
5587            }
5588        }
5589    }
5590
5591    fn handle_mount_floating_widget(
5592        &mut self,
5593        panel_id: u64,
5594        spec: fresh_core::api::WidgetSpec,
5595        width_pct: u8,
5596        height_pct: u8,
5597    ) {
5598        let width_pct = width_pct.clamp(1, 100);
5599        let height_pct = height_pct.clamp(1, 100);
5600        if let Some(existing) = self.floating_widget_panel.take() {
5601            if existing.panel_id != panel_id {
5602                let _ = self.widget_registry.unmount(existing.panel_id);
5603            }
5604        }
5605        self.floating_widget_panel = Some(FloatingWidgetState {
5606            panel_id,
5607            width_pct,
5608            height_pct,
5609            entries: Vec::new(),
5610            focus_cursor: None,
5611            embeds: Vec::new(),
5612            overlays: Vec::new(),
5613            scroll_regions: Vec::new(),
5614            scrollbar_tracks: Vec::new(),
5615            scrollbar_mouse: Default::default(),
5616            scrollbar_drag_key: None,
5617            last_inner_rect: None,
5618        });
5619        let prev = std::collections::HashMap::new();
5620        let prev_focus = String::new();
5621        let panel_width = self.floating_panel_inner_width();
5622        let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
5623        let focus_cursor = out.focus_cursor;
5624        let entries = out.entries;
5625        let embeds = out.embeds;
5626        let overlays = out.overlays;
5627        let scroll_regions = out.scroll_regions;
5628        self.widget_registry.mount(
5629            panel_id,
5630            FLOATING_PANEL_BUFFER_ID,
5631            spec,
5632            out.hits,
5633            out.instance_states,
5634            out.focus_key,
5635            out.tabbable,
5636        );
5637        if let Some(fwp) = self.floating_widget_panel.as_mut() {
5638            fwp.entries = entries;
5639            fwp.focus_cursor = focus_cursor;
5640            fwp.embeds = embeds;
5641            fwp.overlays = overlays;
5642            fwp.scroll_regions = scroll_regions;
5643        }
5644        tracing::debug!(
5645            "Mounted floating widget panel {} ({}%x{}%)",
5646            panel_id,
5647            width_pct,
5648            height_pct
5649        );
5650    }
5651
5652    fn handle_update_floating_widget(&mut self, panel_id: u64, spec: fresh_core::api::WidgetSpec) {
5653        match self.floating_widget_panel.as_ref() {
5654            Some(fwp) if fwp.panel_id == panel_id => {}
5655            _ => {
5656                tracing::debug!(
5657                    "UpdateFloatingWidget for unknown / mismatched panel {} ignored",
5658                    panel_id
5659                );
5660                return;
5661            }
5662        }
5663        let prev = self
5664            .widget_registry
5665            .instance_states(panel_id)
5666            .cloned()
5667            .unwrap_or_default();
5668        let prev_focus = self
5669            .widget_registry
5670            .focus_key(panel_id)
5671            .map(|s| s.to_string())
5672            .unwrap_or_default();
5673        let panel_width = self.floating_panel_inner_width();
5674        let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
5675        let focus_cursor = out.focus_cursor;
5676        let entries = out.entries;
5677        let embeds = out.embeds;
5678        let overlays = out.overlays;
5679        let scroll_regions = out.scroll_regions;
5680        if self
5681            .widget_registry
5682            .update(
5683                panel_id,
5684                spec,
5685                out.hits,
5686                out.instance_states,
5687                out.focus_key,
5688                out.tabbable,
5689            )
5690            .is_err()
5691        {
5692            tracing::debug!(
5693                "UpdateFloatingWidget for unknown panel {} ignored (not in registry)",
5694                panel_id
5695            );
5696            return;
5697        }
5698        if let Some(fwp) = self.floating_widget_panel.as_mut() {
5699            fwp.entries = entries;
5700            fwp.focus_cursor = focus_cursor;
5701            fwp.embeds = embeds;
5702            fwp.overlays = overlays;
5703            fwp.scroll_regions = scroll_regions;
5704        }
5705    }
5706
5707    fn handle_unmount_floating_widget(&mut self, panel_id: u64) {
5708        match self.floating_widget_panel.as_ref() {
5709            Some(fwp) if fwp.panel_id == panel_id => {}
5710            _ => {
5711                tracing::debug!(
5712                    "UnmountFloatingWidget for unknown / mismatched panel {} ignored",
5713                    panel_id
5714                );
5715                return;
5716            }
5717        }
5718        self.floating_widget_panel = None;
5719        let _ = self.widget_registry.unmount(panel_id);
5720        // Restore the active window's visible terminal PTYs to their
5721        // dive-view split rects. The orchestrator picker's preview
5722        // pane shrinks PTYs to the embed size on every frame while
5723        // it's up (see `render_session_preview_into_rect`); when the
5724        // picker closes onto the *same* session the user was
5725        // previewing, `set_active_window` short-circuits because the
5726        // active pointer didn't move, and the shrink-down never gets
5727        // undone — top / vim / etc. keep drawing at the embed's ~15
5728        // rows. Resizing here on every panel unmount restores the
5729        // full dive-view dimensions; for panels that didn't preview
5730        // anything (the new-session form, plugin overlays) this is a
5731        // cheap no-op because the PTY sizes already match.
5732        self.active_window_mut().resize_visible_terminals();
5733        tracing::debug!("Unmounted floating widget panel {}", panel_id);
5734    }
5735
5736    /// Inner-rect column budget for a floating panel render — the
5737    /// terminal width × `width_pct`, minus 2 cols for the frame
5738    /// border. Mirrors the `widget_panel_width` reservation; never
5739    /// goes below 10 cols so flex spacers don't collapse to zero on
5740    /// narrow terminals.
5741    pub(super) fn floating_panel_inner_width(&self) -> u32 {
5742        let term_w = self.terminal_width.max(1) as u32;
5743        let pct = self
5744            .floating_widget_panel
5745            .as_ref()
5746            .map(|f| f.width_pct.clamp(1, 100) as u32)
5747            .unwrap_or(80);
5748        let w = (term_w * pct) / 100;
5749        w.saturating_sub(2).max(10)
5750    }
5751
5752    fn handle_get_text_properties_at_cursor(&self, buffer_id: BufferId) {
5753        if let Some(state) = self
5754            .windows
5755            .get(&self.active_window)
5756            .map(|w| &w.buffers)
5757            .expect("active window present")
5758            .get(&buffer_id)
5759        {
5760            let cursor_pos = self
5761                .windows
5762                .get(&self.active_window)
5763                .and_then(|w| w.buffers.splits())
5764                .map(|(_, vs)| vs)
5765                .expect("active window must have a populated split layout")
5766                .values()
5767                .find_map(|vs| vs.buffer_state(buffer_id))
5768                .map(|bs| bs.cursors.primary().position)
5769                .unwrap_or(0);
5770            let properties = state.text_properties.get_at(cursor_pos);
5771            tracing::debug!(
5772                "Text properties at cursor in {:?}: {} properties found",
5773                buffer_id,
5774                properties.len()
5775            );
5776            // TODO: Fire hook with properties data for plugins to consume
5777        }
5778    }
5779
5780    fn handle_set_context(&mut self, name: String, active: bool) {
5781        if active {
5782            self.active_window_mut()
5783                .active_custom_contexts
5784                .insert(name.clone());
5785            tracing::debug!("Set custom context: {}", name);
5786        } else {
5787            self.active_window_mut()
5788                .active_custom_contexts
5789                .remove(&name);
5790            tracing::debug!("Unset custom context: {}", name);
5791        }
5792    }
5793
5794    fn handle_disable_lsp_for_language(&mut self, language: String) {
5795        tracing::info!("Disabling LSP for language: {}", language);
5796        let __active_id = self.active_window;
5797        if let Some(lsp) = self
5798            .windows
5799            .get_mut(&__active_id)
5800            .and_then(|w| w.lsp.as_mut())
5801        {
5802            lsp.shutdown_server(&language);
5803            tracing::info!("Stopped LSP server for {}", language);
5804        }
5805        if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
5806            for c in lsp_configs.as_mut_slice() {
5807                c.enabled = false;
5808                c.auto_start = false;
5809            }
5810            tracing::info!("Disabled LSP config for {}", language);
5811        }
5812        if let Err(e) = self.save_config() {
5813            tracing::error!("Failed to save config: {}", e);
5814            self.active_window_mut().status_message = Some(format!(
5815                "LSP disabled for {} (config save failed)",
5816                language
5817            ));
5818        } else {
5819            self.active_window_mut().status_message =
5820                Some(format!("LSP disabled for {}", language));
5821        }
5822        self.active_window_mut().warning_domains.lsp.clear();
5823    }
5824
5825    fn handle_restart_lsp_for_language(&mut self, language: String) {
5826        tracing::info!("Plugin restarting LSP for language: {}", language);
5827        let file_path = self
5828            .active_window()
5829            .buffer_metadata
5830            .get(&self.active_buffer())
5831            .and_then(|meta| meta.file_path().cloned());
5832        let __active_id = self.active_window;
5833        let success = if let Some(lsp) = self
5834            .windows
5835            .get_mut(&__active_id)
5836            .and_then(|w| w.lsp.as_mut())
5837        {
5838            let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
5839            self.active_window_mut().status_message = Some(msg);
5840            ok
5841        } else {
5842            self.active_window_mut().status_message = Some("No LSP manager available".to_string());
5843            false
5844        };
5845        if success {
5846            self.reopen_buffers_for_language(&language);
5847        }
5848    }
5849
5850    fn handle_set_lsp_root_uri(&mut self, language: String, uri: String) {
5851        tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
5852        match uri.parse::<lsp_types::Uri>() {
5853            Ok(parsed_uri) => {
5854                let __active_id = self.active_window;
5855                if let Some(lsp) = self
5856                    .windows
5857                    .get_mut(&__active_id)
5858                    .and_then(|w| w.lsp.as_mut())
5859                {
5860                    let restarted = lsp.set_language_root_uri(&language, parsed_uri);
5861                    if restarted {
5862                        self.active_window_mut().status_message = Some(format!(
5863                            "LSP root updated for {} (restarting server)",
5864                            language
5865                        ));
5866                    } else {
5867                        self.active_window_mut().status_message =
5868                            Some(format!("LSP root set for {}", language));
5869                    }
5870                }
5871            }
5872            Err(e) => {
5873                tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
5874                self.active_window_mut().status_message =
5875                    Some(format!("Invalid LSP root URI: {}", e));
5876            }
5877        }
5878    }
5879
5880    fn handle_create_scroll_sync_group(
5881        &mut self,
5882        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5883        left_split: SplitId,
5884        right_split: SplitId,
5885    ) {
5886        let success = self
5887            .active_window_mut()
5888            .scroll_sync_manager
5889            .create_group_with_id(group_id, left_split, right_split);
5890        if success {
5891            tracing::debug!(
5892                "Created scroll sync group {} for splits {:?} and {:?}",
5893                group_id,
5894                left_split,
5895                right_split
5896            );
5897        } else {
5898            tracing::warn!(
5899                "Failed to create scroll sync group {} (ID already exists)",
5900                group_id
5901            );
5902        }
5903    }
5904
5905    fn handle_set_scroll_sync_anchors(
5906        &mut self,
5907        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5908        anchors: Vec<(usize, usize)>,
5909    ) {
5910        use crate::view::scroll_sync::SyncAnchor;
5911        let anchor_count = anchors.len();
5912        let sync_anchors: Vec<SyncAnchor> = anchors
5913            .into_iter()
5914            .map(|(left_line, right_line)| SyncAnchor {
5915                left_line,
5916                right_line,
5917            })
5918            .collect();
5919        self.active_window_mut()
5920            .scroll_sync_manager
5921            .set_anchors(group_id, sync_anchors);
5922        tracing::debug!(
5923            "Set {} anchors for scroll sync group {}",
5924            anchor_count,
5925            group_id
5926        );
5927    }
5928
5929    fn handle_remove_scroll_sync_group(
5930        &mut self,
5931        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5932    ) {
5933        if self
5934            .active_window_mut()
5935            .scroll_sync_manager
5936            .remove_group(group_id)
5937        {
5938            tracing::debug!("Removed scroll sync group {}", group_id);
5939        } else {
5940            tracing::warn!("Scroll sync group {} not found", group_id);
5941        }
5942    }
5943
5944    fn handle_create_buffer_group(
5945        &mut self,
5946        name: String,
5947        mode: String,
5948        layout_json: String,
5949        request_id: Option<u64>,
5950    ) {
5951        match self.create_buffer_group(name, mode, layout_json) {
5952            Ok(result) => {
5953                if let Some(req_id) = request_id {
5954                    let json = serde_json::to_string(&result).unwrap_or_default();
5955                    self.plugin_manager
5956                        .read()
5957                        .unwrap()
5958                        .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
5959                }
5960            }
5961            Err(e) => {
5962                tracing::error!("Failed to create buffer group: {}", e);
5963            }
5964        }
5965    }
5966
5967    fn handle_send_terminal_input(
5968        &mut self,
5969        terminal_id: crate::services::terminal::TerminalId,
5970        data: String,
5971    ) {
5972        if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
5973            handle.write(data.as_bytes());
5974            tracing::trace!(
5975                "Plugin sent {} bytes to terminal {:?}",
5976                data.len(),
5977                terminal_id
5978            );
5979        } else {
5980            tracing::warn!(
5981                "Plugin tried to send input to non-existent terminal {:?}",
5982                terminal_id
5983            );
5984        }
5985    }
5986
5987    fn handle_close_terminal(&mut self, terminal_id: crate::services::terminal::TerminalId) {
5988        let buffer_to_close = self
5989            .active_window()
5990            .terminal_buffers
5991            .iter()
5992            .find(|(_, &tid)| tid == terminal_id)
5993            .map(|(&bid, _)| bid);
5994        if let Some(buffer_id) = buffer_to_close {
5995            if let Err(e) = self.close_buffer(buffer_id) {
5996                tracing::warn!("Failed to close terminal buffer: {}", e);
5997            }
5998            tracing::info!("Plugin closed terminal {:?}", terminal_id);
5999        } else {
6000            self.active_window_mut().terminal_manager.close(terminal_id);
6001            tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
6002        }
6003    }
6004
6005    /// Fan `signal` out to every process group the window
6006    /// identified by `id` is tracking. The window's authority-
6007    /// configured signaller (see `app/window/process_group.rs`)
6008    /// decides how the signal is delivered. Failures from
6009    /// individual groups land in the tracing log so a partial
6010    /// failure surfaces without aborting the rest of the
6011    /// stop flow.
6012    fn handle_signal_window(&mut self, id: fresh_core::WindowId, signal: &str) {
6013        let Some(window) = self.windows.get_mut(&id) else {
6014            tracing::warn!("Plugin SignalWindow targeted unknown window {:?}", id);
6015            return;
6016        };
6017        let results = window.process_groups.signal_all(signal);
6018        for (entry, result) in results {
6019            match result {
6020                Ok(true) => tracing::info!(
6021                    "SignalWindow {:?}: {} → pid {} ({})",
6022                    id,
6023                    signal,
6024                    entry.leader_pid,
6025                    entry.label
6026                ),
6027                Ok(false) => tracing::debug!(
6028                    "SignalWindow {:?}: pid {} ({}) already exited",
6029                    id,
6030                    entry.leader_pid,
6031                    entry.label
6032                ),
6033                Err(e) => tracing::warn!(
6034                    "SignalWindow {:?}: pid {} ({}): {}",
6035                    id,
6036                    entry.leader_pid,
6037                    entry.label,
6038                    e
6039                ),
6040            }
6041        }
6042    }
6043}
6044
6045#[cfg(test)]
6046mod tests {
6047    //! Focused tests for the SpawnHostProcess kill mechanism.
6048    //!
6049    //! These don't exercise the full `handle_plugin_command` dispatcher
6050    //! (which would require scaffolding an Editor with a real tokio
6051    //! runtime and async_bridge); they replicate the inner
6052    //! `tokio::select!` pattern directly on a real subprocess. A
6053    //! regression in the select arms or in the kill-then-wait
6054    //! sequencing would reproduce here.
6055    //!
6056    //! The dispatcher-level integration coverage comes from the e2e
6057    //! attach-cancel test in `tests/e2e/` — this unit test is the
6058    //! lower-level pin.
6059    use tokio::io::{AsyncReadExt, BufReader};
6060    use tokio::process::Command as TokioCommand;
6061    use tokio::time::{timeout, Duration};
6062
6063    /// A long-sleep child that runs `tokio::select! { wait | kill_rx }`
6064    /// terminates when the kill channel fires, and the terminal exit
6065    /// code reflects signal termination (non-zero / None).
6066    ///
6067    /// Spawns `sleep` directly rather than through `sh -c` so SIGKILL
6068    /// reaches the process whose pipe our reader futures hold —
6069    /// `sh -c sleep` leaks the sleep child on SIGKILL (Q-C2), the
6070    /// pipe stays open, and the reader future hangs. That's a
6071    /// deliberate known limitation of start_kill; this test
6072    /// exercises the clean path.
6073    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6074    async fn kill_via_oneshot_terminates_long_running_child() {
6075        let mut cmd = TokioCommand::new("sleep");
6076        cmd.args(["30"]);
6077        cmd.stdout(std::process::Stdio::piped());
6078        cmd.stderr(std::process::Stdio::piped());
6079
6080        let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
6081        let pid = child.id().expect("child has a pid");
6082
6083        let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
6084        let stdout_pipe = child.stdout.take();
6085        let stderr_pipe = child.stderr.take();
6086
6087        let stdout_fut = async {
6088            let mut buf = String::new();
6089            if let Some(s) = stdout_pipe {
6090                #[allow(clippy::let_underscore_must_use)]
6091                let _ = BufReader::new(s).read_to_string(&mut buf).await;
6092            }
6093            buf
6094        };
6095        let stderr_fut = async {
6096            let mut buf = String::new();
6097            if let Some(s) = stderr_pipe {
6098                #[allow(clippy::let_underscore_must_use)]
6099                let _ = BufReader::new(s).read_to_string(&mut buf).await;
6100            }
6101            buf
6102        };
6103        let wait_fut = async {
6104            tokio::select! {
6105                status = child.wait() => {
6106                    status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
6107                }
6108                _ = &mut kill_rx => {
6109                    #[allow(clippy::let_underscore_must_use)]
6110                    let _ = child.start_kill();
6111                    child
6112                        .wait()
6113                        .await
6114                        .map(|s| s.code().unwrap_or(-1))
6115                        .unwrap_or(-1)
6116                }
6117            }
6118        };
6119
6120        // Give the shell a moment to install itself — firing kill
6121        // against an not-yet-existent child is still valid (SIGKILL
6122        // to a zombie is a no-op) but we want to actually exercise
6123        // the running-child path.
6124        tokio::time::sleep(Duration::from_millis(50)).await;
6125        kill_tx.send(()).expect("kill channel send");
6126
6127        let result = timeout(Duration::from_secs(5), async {
6128            tokio::join!(stdout_fut, stderr_fut, wait_fut)
6129        })
6130        .await;
6131
6132        let (_stdout, _stderr, exit_code) = result.expect(
6133            "kill path must resolve within 5s — if this times out the \
6134             select! arm order or kill-then-wait logic is broken",
6135        );
6136        // The cross-platform invariant is "the child did not complete
6137        // its 30s sleep" — i.e. the exit code is non-success. Platform
6138        // specifics:
6139        //   - Unix: `start_kill()` sends SIGKILL; `ExitStatus::code()`
6140        //     returns None for signal-terminated processes, which our
6141        //     dispatcher maps to -1 via `.unwrap_or(-1)`.
6142        //   - Windows: `start_kill()` calls `TerminateProcess(..., 1)`;
6143        //     `code()` returns `Some(1)`, mapped to 1 by the same
6144        //     `.unwrap_or(-1)`.
6145        // A successful 30s sleep would yield 0 — that's the
6146        // regression case we're guarding against.
6147        assert_ne!(
6148            exit_code, 0,
6149            "killed child must exit non-success (got 0 — did the \
6150             kill arm fire too late, or did sleep somehow complete?)"
6151        );
6152
6153        // Sanity: on Unix the child must be gone. `kill -0 <pid>`
6154        // returns 0 iff the process still exists; we expect non-zero
6155        // (No such process) after wait(). This catches a zombie /
6156        // leaked child that would indicate we skipped the wait() on
6157        // the kill path. Skipped on Windows — `kill` isn't available
6158        // and `tasklist` output parsing is more noise than signal
6159        // for this one-shot check; the wait() having returned is
6160        // already evidence of reap there.
6161        #[cfg(unix)]
6162        {
6163            let still_alive = std::process::Command::new("kill")
6164                .args(["-0", &pid.to_string()])
6165                .status()
6166                .map(|s| s.success())
6167                .unwrap_or(false);
6168            assert!(
6169                !still_alive,
6170                "process {pid} must be reaped after wait() — a still-\
6171                 alive check means the kill path leaked the child"
6172            );
6173        }
6174        #[cfg(not(unix))]
6175        {
6176            // Touch `pid` so the unused-variable lint doesn't fire on
6177            // non-Unix builds.
6178            let _ = pid;
6179        }
6180    }
6181}
6182
6183impl Window {
6184    /// Populate the per-window fields of the plugin state snapshot.
6185    ///
6186    /// Called by `Editor::update_plugin_state_snapshot` while it holds
6187    /// the snapshot write lock. Covers everything that a single Window
6188    /// owns: active buffer/split ids, all this window's buffers (with
6189    /// per-buffer view-mode, compose state, preview flag, split
6190    /// membership), per-buffer cursor positions and text properties,
6191    /// the active buffer's cursors / viewport / selected text, the
6192    /// per-split snapshot list, this window's active-session plugin
6193    /// state, this window's authority label, diagnostics, folding
6194    /// ranges, editor mode, and the per-window plugin view states.
6195    /// Editor-wide fields (clipboard, windows list, config cache,
6196    /// user_config_raw, plugin_global_state) are populated by the
6197    /// Editor coda after this returns.
6198    #[cfg(feature = "plugins")]
6199    pub(crate) fn populate_plugin_state_snapshot(
6200        &mut self,
6201        snapshot: &mut fresh_core::api::EditorStateSnapshot,
6202    ) {
6203        use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
6204
6205        // Rebuild only on registry mutation. Compares the registry's
6206        // monotonic catalog_gen against the last-seen value on the
6207        // snapshot — a single integer check, no allocation, no
6208        // count-mismatch ambiguity between the syntect set and the
6209        // unified catalog.
6210        let current_gen = self.resources.grammar_registry.catalog_gen();
6211        if snapshot.last_grammar_gen != current_gen {
6212            snapshot.available_grammars = self
6213                .resources
6214                .grammar_registry
6215                .available_grammar_info()
6216                .into_iter()
6217                .map(|g| fresh_core::api::GrammarInfoSnapshot {
6218                    name: g.name,
6219                    source: g.source.to_string(),
6220                    file_extensions: g.file_extensions,
6221                    short_name: g.short_name,
6222                })
6223                .collect();
6224            snapshot.last_grammar_gen = current_gen;
6225        }
6226
6227        snapshot.active_buffer_id = self.active_buffer();
6228
6229        let (mgr_ref, vs_ref) = self
6230            .buffers
6231            .splits()
6232            .expect("active window must have a populated split layout");
6233        let active_split = mgr_ref.active_split();
6234        snapshot.active_split_id = active_split.0 .0;
6235
6236        // Clear and update buffer info
6237        snapshot.buffers.clear();
6238        snapshot.buffer_saved_diffs.clear();
6239        snapshot.buffer_cursor_positions.clear();
6240        snapshot.buffer_text_properties.clear();
6241
6242        let active_vs_opt = vs_ref.get(&active_split);
6243        for (buffer_id, state) in &self.buffers {
6244            let is_virtual = self
6245                .buffer_metadata
6246                .get(buffer_id)
6247                .map(|m| m.is_virtual())
6248                .unwrap_or(false);
6249            // Report the ACTIVE split's view_mode so plugins can distinguish
6250            // which mode the user is currently in. Separately, report whether
6251            // ANY split has compose mode so plugins can maintain decorations
6252            // for compose-mode splits even when a source-mode split is active.
6253            let view_mode = active_vs_opt
6254                .and_then(|vs| vs.buffer_state(*buffer_id))
6255                .map(|bs| match bs.view_mode {
6256                    crate::state::ViewMode::Source => "source",
6257                    crate::state::ViewMode::PageView => "compose",
6258                })
6259                .unwrap_or("source");
6260            let compose_width = active_vs_opt
6261                .and_then(|vs| vs.buffer_state(*buffer_id))
6262                .and_then(|bs| bs.compose_width);
6263            let is_composing_in_any_split = vs_ref.values().any(|vs| {
6264                vs.buffer_state(*buffer_id)
6265                    .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
6266                    .unwrap_or(false)
6267            });
6268            let is_preview = self
6269                .buffer_metadata
6270                .get(buffer_id)
6271                .map(|m| m.is_preview)
6272                .unwrap_or(false);
6273            // Which splits currently hold this buffer — lets plugins
6274            // implement "focus existing if visible, else open new"
6275            // without tracking split ids across editor restarts
6276            // (the restart reassigns them). SplitManager has the
6277            // authoritative map; we just mirror it.
6278            let splits: Vec<fresh_core::SplitId> = mgr_ref
6279                .splits_for_buffer(*buffer_id)
6280                .into_iter()
6281                .map(|leaf_id| leaf_id.0)
6282                .collect();
6283            let buffer_info = BufferInfo {
6284                id: *buffer_id,
6285                path: state.buffer.file_path().map(|p| p.to_path_buf()),
6286                modified: state.buffer.is_modified(),
6287                length: state.buffer.len(),
6288                is_virtual,
6289                view_mode: view_mode.to_string(),
6290                is_composing_in_any_split,
6291                compose_width,
6292                language: state.language.clone(),
6293                is_preview,
6294                splits,
6295            };
6296            snapshot.buffers.insert(*buffer_id, buffer_info);
6297
6298            let diff = {
6299                let diff = state.buffer.diff_since_saved();
6300                BufferSavedDiff {
6301                    equal: diff.equal,
6302                    byte_ranges: diff.byte_ranges.clone(),
6303                }
6304            };
6305            snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
6306
6307            // Regular buffers live in exactly one split's keyed_states.
6308            // Panel (hidden) buffers natively live inside a group's inner
6309            // split — but the close-buffer path can leave a *shadow*
6310            // entry in the group's host split (from `switch_buffer`'s
6311            // auto-insert, kept to preserve the
6312            // `active_buffer ∈ keyed_states` invariant). For hidden
6313            // buffers we therefore skip group-host splits and pick the
6314            // inner split, which is the authoritative home.
6315            let is_hidden = self
6316                .buffer_metadata
6317                .get(buffer_id)
6318                .is_some_and(|m| m.hidden_from_tabs);
6319            let source_split = vs_ref.iter().find(|(split_id, vs)| {
6320                vs.keyed_states.contains_key(buffer_id)
6321                    && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
6322            });
6323            let cursor_pos = source_split
6324                .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
6325                .map(|bs| bs.cursors.primary().position)
6326                .unwrap_or(0);
6327            tracing::trace!(
6328                "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
6329                buffer_id,
6330                cursor_pos,
6331                source_split.map(|(id, _)| *id),
6332            );
6333            snapshot
6334                .buffer_cursor_positions
6335                .insert(*buffer_id, cursor_pos);
6336
6337            // Store text properties if this buffer has any
6338            if !state.text_properties.is_empty() {
6339                snapshot
6340                    .buffer_text_properties
6341                    .insert(*buffer_id, state.text_properties.all().to_vec());
6342            }
6343        }
6344
6345        // Update cursor information for active buffer.
6346        //
6347        // Use `effective_active_pair()` for the split id rather than
6348        // the split manager's outer `active_split()`. When the active
6349        // split holds a buffer-group tab, the user's keystrokes (and
6350        // therefore the meaningful cursor) live in the focused inner
6351        // panel's leaf — `focused_group_leaf` — not the outer leaf.
6352        // Reading the outer's cursor here would publish (0, 0) into
6353        // the snapshot while the user is editing the inner panel,
6354        // which is what `editor.getCursorPosition()` then sees.
6355        let active_buf_id = snapshot.active_buffer_id;
6356        let active_split_id = self.effective_active_pair().0;
6357        self.buffers
6358            .with_all_mut(|buffers_mut, mgr, vs_map| {
6359                let _ = mgr; // active_split_id was computed above
6360                if let Some(active_vs) = vs_map.get(&active_split_id) {
6361                    // Primary cursor (from SplitViewState)
6362                    let active_cursors = &active_vs.cursors;
6363                    let primary = active_cursors.primary();
6364                    let primary_position = primary.position;
6365                    let primary_selection = primary.selection_range();
6366
6367                    // Resolve a byte offset to its 0-indexed line, but only when the
6368                    // active buffer has a line index. Huge files load without line
6369                    // metadata (`line_count() == None`); reporting `0` there would be
6370                    // a lie, so we surface `None` instead — the same guard the
6371                    // viewport's `top_line` uses below.
6372                    let line_of = |offset: usize| -> Option<usize> {
6373                        buffers_mut.get(&active_buf_id).and_then(|state| {
6374                            if state.buffer.line_count().is_some() {
6375                                Some(state.buffer.get_line_number(offset))
6376                            } else {
6377                                None
6378                            }
6379                        })
6380                    };
6381
6382                    snapshot.primary_cursor = Some(CursorInfo {
6383                        position: primary_position,
6384                        selection: primary_selection.clone(),
6385                        line: line_of(primary_position),
6386                    });
6387
6388                    snapshot.all_cursors = active_cursors
6389                        .iter()
6390                        .map(|(_, cursor)| CursorInfo {
6391                            position: cursor.position,
6392                            selection: cursor.selection_range(),
6393                            line: line_of(cursor.position),
6394                        })
6395                        .collect();
6396
6397                    // Selected text from primary cursor (for clipboard plugin)
6398                    if let Some(range) = primary_selection {
6399                        if let Some(active_state) = buffers_mut.get_mut(&active_buf_id) {
6400                            snapshot.selected_text =
6401                                Some(active_state.get_text_range(range.start, range.end));
6402                        }
6403                    }
6404
6405                    // Viewport — get from SplitViewState (the authoritative source)
6406                    let top_line = buffers_mut.get(&active_buf_id).and_then(|state| {
6407                        if state.buffer.line_count().is_some() {
6408                            Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
6409                        } else {
6410                            None
6411                        }
6412                    });
6413                    snapshot.viewport = Some(ViewportInfo {
6414                        top_byte: active_vs.viewport.top_byte,
6415                        top_line,
6416                        left_column: active_vs.viewport.left_column,
6417                        width: active_vs.viewport.width,
6418                        height: active_vs.viewport.height,
6419                    });
6420                } else {
6421                    snapshot.primary_cursor = None;
6422                    snapshot.all_cursors.clear();
6423                    snapshot.viewport = None;
6424                    snapshot.selected_text = None;
6425                }
6426
6427                // Per-split snapshot
6428                snapshot.splits.clear();
6429                for (leaf_id, vs) in vs_map.iter() {
6430                    let buf_id = vs.active_buffer;
6431                    let top_line = buffers_mut.get(&buf_id).and_then(|state| {
6432                        if state.buffer.line_count().is_some() {
6433                            Some(state.buffer.get_line_number(vs.viewport.top_byte))
6434                        } else {
6435                            None
6436                        }
6437                    });
6438                    snapshot.splits.push(fresh_core::api::SplitSnapshot {
6439                        split_id: leaf_id.0 .0,
6440                        buffer_id: buf_id,
6441                        viewport: ViewportInfo {
6442                            top_byte: vs.viewport.top_byte,
6443                            top_line,
6444                            left_column: vs.viewport.left_column,
6445                            width: vs.viewport.width,
6446                            height: vs.viewport.height,
6447                        },
6448                    });
6449                }
6450            })
6451            .expect("active window must have a populated split layout");
6452
6453        // Mirror the active session's plugin_state into the snapshot
6454        // so getWindowState reads cheaply. Cloning is fine here: the
6455        // per-session state is small; plugins that store megabyte-
6456        // scale blobs in setWindowState will see proportional snapshot-
6457        // update cost, which is the desired feedback signal.
6458        snapshot.active_session_plugin_states = self.plugin_state.clone();
6459        // `authority_label` is populated by the Editor coda — see the
6460        // comment there for why it can't come from `self.resources`.
6461
6462        // Update LSP diagnostics / folding ranges: Arc refcount bumps.
6463        snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
6464        snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
6465
6466        // Update editor mode (for vi mode and other modal editing)
6467        snapshot.editor_mode = self.editor_mode.clone();
6468
6469        // Update plugin view states from active split's BufferViewState.plugin_state.
6470        // If the active split changed, fully repopulate. Otherwise, merge
6471        // using or_insert to preserve JS-side write-through entries that
6472        // haven't round-tripped through the command channel yet.
6473        let active_split_id_u64 = active_split_id.0 .0;
6474        let split_changed = snapshot.plugin_view_states_split != active_split_id_u64;
6475        if split_changed {
6476            snapshot.plugin_view_states.clear();
6477            snapshot.plugin_view_states_split = active_split_id_u64;
6478        }
6479
6480        // Clean up entries for buffers that are no longer open
6481        {
6482            let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
6483            snapshot
6484                .plugin_view_states
6485                .retain(|bid, _| open_bids.contains(bid));
6486        }
6487
6488        // Merge from Rust-side plugin_state (source of truth for persisted state)
6489        if let Some(vs_map) = self.buffers.split_view_states() {
6490            if let Some(active_vs) = vs_map.get(&active_split_id) {
6491                for (buffer_id, buf_state) in &active_vs.keyed_states {
6492                    if !buf_state.plugin_state.is_empty() {
6493                        let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
6494                        for (key, value) in &buf_state.plugin_state {
6495                            entry.entry(key.clone()).or_insert_with(|| value.clone());
6496                        }
6497                    }
6498                }
6499            }
6500        }
6501    }
6502}
6503
6504/// Maximum size of a body downloaded via `editor.httpFetch`. 64 MB is well
6505/// above any reasonable theme/plugin asset (themes are tens of KB) while
6506/// still capping a misbehaving server's blast radius.
6507const HTTP_FETCH_MAX_BYTES: u64 = 64 * 1024 * 1024;
6508
6509/// Fetch a URL over HTTP(S) and stream the response body into `target`.
6510///
6511/// Returns the HTTP status code on success. Non-2xx responses are returned
6512/// as their status code without writing to the target file. Transport
6513/// errors (DNS, TLS, timeout, …) are returned as `Err`.
6514fn fetch_url_to_file(url: &str, target: &std::path::Path) -> Result<u16, String> {
6515    // Use the platform's native certificate verifier so requests work in
6516    // environments with TLS-intercepting proxies or custom enterprise root
6517    // CAs that aren't in Mozilla's bundled webpki-roots.
6518    let tls_config = ureq::tls::TlsConfig::builder()
6519        .root_certs(ureq::tls::RootCerts::PlatformVerifier)
6520        .build();
6521
6522    let agent = ureq::Agent::config_builder()
6523        .timeout_global(Some(std::time::Duration::from_secs(30)))
6524        .http_status_as_error(false)
6525        .tls_config(tls_config)
6526        .build()
6527        .new_agent();
6528
6529    let response = agent
6530        .get(url)
6531        .header("User-Agent", "fresh-editor")
6532        .call()
6533        .map_err(|e| format!("HTTP request failed: {}", e))?;
6534
6535    let status = response.status().as_u16();
6536    if !(200..300).contains(&status) {
6537        return Ok(status);
6538    }
6539
6540    let mut file = std::fs::File::create(target)
6541        .map_err(|e| format!("failed to create {}: {}", target.display(), e))?;
6542
6543    let mut reader = response
6544        .into_body()
6545        .into_with_config()
6546        .limit(HTTP_FETCH_MAX_BYTES)
6547        .reader();
6548
6549    std::io::copy(&mut reader, &mut file)
6550        .map_err(|e| format!("failed to write response body: {}", e))?;
6551
6552    Ok(status)
6553}