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