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