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