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