Skip to main content

fresh/app/
plugin_dispatch.rs

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