Skip to main content

fresh/app/
plugin_dispatch.rs

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