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