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::Editor;
25
26/// Returns the byte offset of the start (want_end=false) or end (want_end=true)
27/// of `line` (0-indexed) within `content`. Returns `None` when `line` is out of
28/// range. The "end" position is the byte index of the terminating `\n`; for the
29/// last line with no trailing newline it is `buffer_len`.
30fn buffer_line_byte_offset(
31    content: &str,
32    buffer_len: usize,
33    line: usize,
34    want_end: bool,
35) -> Option<usize> {
36    if !want_end && line == 0 {
37        return Some(0);
38    }
39    let mut current_line = 0usize;
40    for (byte_idx, c) in content.char_indices() {
41        if c == '\n' {
42            if want_end && current_line == line {
43                return Some(byte_idx);
44            }
45            current_line += 1;
46            if !want_end && current_line == line {
47                return Some(byte_idx + 1);
48            }
49        }
50    }
51    if want_end && current_line == line {
52        Some(buffer_len)
53    } else {
54        None
55    }
56}
57
58impl Editor {
59    /// Update the plugin state snapshot with current editor state
60    #[cfg(feature = "plugins")]
61    pub(super) fn update_plugin_state_snapshot(&mut self) {
62        // Update TypeScript plugin manager state
63        if let Some(snapshot_handle) = self.plugin_manager.state_snapshot_handle() {
64            use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
65            let mut snapshot = snapshot_handle.write().unwrap();
66
67            // Rebuild only on registry mutation. Compares the registry's
68            // monotonic catalog_gen against the last-seen value on the
69            // snapshot — a single integer check, no allocation, no
70            // count-mismatch ambiguity between the syntect set and the
71            // unified catalog.
72            let current_gen = self.grammar_registry.catalog_gen();
73            if snapshot.last_grammar_gen != current_gen {
74                snapshot.available_grammars = self
75                    .grammar_registry
76                    .available_grammar_info()
77                    .into_iter()
78                    .map(|g| fresh_core::api::GrammarInfoSnapshot {
79                        name: g.name,
80                        source: g.source.to_string(),
81                        file_extensions: g.file_extensions,
82                        short_name: g.short_name,
83                    })
84                    .collect();
85                snapshot.last_grammar_gen = current_gen;
86            }
87
88            // Update active buffer ID
89            snapshot.active_buffer_id = self.active_buffer();
90
91            // Update active split ID
92            snapshot.active_split_id = self.split_manager.active_split().0 .0;
93
94            // Clear and update buffer info
95            snapshot.buffers.clear();
96            snapshot.buffer_saved_diffs.clear();
97            snapshot.buffer_cursor_positions.clear();
98            snapshot.buffer_text_properties.clear();
99
100            for (buffer_id, state) in &self.buffers {
101                let is_virtual = self
102                    .buffer_metadata
103                    .get(buffer_id)
104                    .map(|m| m.is_virtual())
105                    .unwrap_or(false);
106                // Report the ACTIVE split's view_mode so plugins can distinguish
107                // which mode the user is currently in. Separately, report whether
108                // ANY split has compose mode so plugins can maintain decorations
109                // for compose-mode splits even when a source-mode split is active.
110                let active_split = self.split_manager.active_split();
111                let active_vs = self.split_view_states.get(&active_split);
112                let view_mode = active_vs
113                    .and_then(|vs| vs.buffer_state(*buffer_id))
114                    .map(|bs| match bs.view_mode {
115                        crate::state::ViewMode::Source => "source",
116                        crate::state::ViewMode::PageView => "compose",
117                    })
118                    .unwrap_or("source");
119                let compose_width = active_vs
120                    .and_then(|vs| vs.buffer_state(*buffer_id))
121                    .and_then(|bs| bs.compose_width);
122                let is_composing_in_any_split = self.split_view_states.values().any(|vs| {
123                    vs.buffer_state(*buffer_id)
124                        .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
125                        .unwrap_or(false)
126                });
127                let is_preview = self
128                    .buffer_metadata
129                    .get(buffer_id)
130                    .map(|m| m.is_preview)
131                    .unwrap_or(false);
132                // Which splits currently hold this buffer — lets plugins
133                // implement "focus existing if visible, else open new"
134                // without tracking split ids across editor restarts
135                // (the restart reassigns them). SplitManager has the
136                // authoritative map; we just mirror it.
137                let splits: Vec<fresh_core::SplitId> = self
138                    .split_manager
139                    .splits_for_buffer(*buffer_id)
140                    .into_iter()
141                    .map(|leaf_id| leaf_id.0)
142                    .collect();
143                let buffer_info = BufferInfo {
144                    id: *buffer_id,
145                    path: state.buffer.file_path().map(|p| p.to_path_buf()),
146                    modified: state.buffer.is_modified(),
147                    length: state.buffer.len(),
148                    is_virtual,
149                    view_mode: view_mode.to_string(),
150                    is_composing_in_any_split,
151                    compose_width,
152                    language: state.language.clone(),
153                    is_preview,
154                    splits,
155                };
156                snapshot.buffers.insert(*buffer_id, buffer_info);
157
158                let diff = {
159                    let diff = state.buffer.diff_since_saved();
160                    BufferSavedDiff {
161                        equal: diff.equal,
162                        byte_ranges: diff.byte_ranges.clone(),
163                    }
164                };
165                snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
166
167                // Regular buffers live in exactly one split's keyed_states.
168                // Panel (hidden) buffers natively live inside a group's inner
169                // split — but the close-buffer path can leave a *shadow*
170                // entry in the group's host split (from `switch_buffer`'s
171                // auto-insert, kept to preserve the
172                // `active_buffer ∈ keyed_states` invariant). For hidden
173                // buffers we therefore skip group-host splits and pick the
174                // inner split, which is the authoritative home.
175                let is_hidden = self
176                    .buffer_metadata
177                    .get(buffer_id)
178                    .is_some_and(|m| m.hidden_from_tabs);
179                let source_split = self.split_view_states.iter().find(|(split_id, vs)| {
180                    vs.keyed_states.contains_key(buffer_id)
181                        && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
182                });
183                let cursor_pos = source_split
184                    .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
185                    .map(|bs| bs.cursors.primary().position)
186                    .unwrap_or(0);
187                tracing::trace!(
188                    "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
189                    buffer_id,
190                    cursor_pos,
191                    source_split.map(|(id, _)| *id),
192                );
193                snapshot
194                    .buffer_cursor_positions
195                    .insert(*buffer_id, cursor_pos);
196
197                // Store text properties if this buffer has any
198                if !state.text_properties.is_empty() {
199                    snapshot
200                        .buffer_text_properties
201                        .insert(*buffer_id, state.text_properties.all().to_vec());
202                }
203            }
204
205            // Update cursor information for active buffer
206            if let Some(active_vs) = self
207                .split_view_states
208                .get(&self.split_manager.active_split())
209            {
210                // Primary cursor (from SplitViewState)
211                let active_cursors = &active_vs.cursors;
212                let primary = active_cursors.primary();
213                let primary_position = primary.position;
214                let primary_selection = primary.selection_range();
215
216                snapshot.primary_cursor = Some(CursorInfo {
217                    position: primary_position,
218                    selection: primary_selection.clone(),
219                });
220
221                // All cursors
222                snapshot.all_cursors = active_cursors
223                    .iter()
224                    .map(|(_, cursor)| CursorInfo {
225                        position: cursor.position,
226                        selection: cursor.selection_range(),
227                    })
228                    .collect();
229
230                // Selected text from primary cursor (for clipboard plugin)
231                if let Some(range) = primary_selection {
232                    if let Some(active_state) = self.buffers.get_mut(&self.active_buffer()) {
233                        snapshot.selected_text =
234                            Some(active_state.get_text_range(range.start, range.end));
235                    }
236                }
237
238                // Viewport - get from SplitViewState (the authoritative source)
239                let top_line = self.buffers.get(&self.active_buffer()).and_then(|state| {
240                    if state.buffer.line_count().is_some() {
241                        Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
242                    } else {
243                        None
244                    }
245                });
246                snapshot.viewport = Some(ViewportInfo {
247                    top_byte: active_vs.viewport.top_byte,
248                    top_line,
249                    left_column: active_vs.viewport.left_column,
250                    width: active_vs.viewport.width,
251                    height: active_vs.viewport.height,
252                });
253            } else {
254                snapshot.primary_cursor = None;
255                snapshot.all_cursors.clear();
256                snapshot.viewport = None;
257                snapshot.selected_text = None;
258            }
259
260            // Per-split snapshot — every split's active buffer + viewport
261            // so plugins (multi-split flash labels, sync decorations,
262            // etc.) can iterate every visible buffer instead of only the
263            // active one.
264            snapshot.splits.clear();
265            for (leaf_id, vs) in &self.split_view_states {
266                let buf_id = vs.active_buffer;
267                let top_line = self.buffers.get(&buf_id).and_then(|state| {
268                    if state.buffer.line_count().is_some() {
269                        Some(state.buffer.get_line_number(vs.viewport.top_byte))
270                    } else {
271                        None
272                    }
273                });
274                snapshot.splits.push(fresh_core::api::SplitSnapshot {
275                    split_id: leaf_id.0 .0,
276                    buffer_id: buf_id,
277                    viewport: ViewportInfo {
278                        top_byte: vs.viewport.top_byte,
279                        top_line,
280                        left_column: vs.viewport.left_column,
281                        width: vs.viewport.width,
282                        height: vs.viewport.height,
283                    },
284                });
285            }
286
287            // Update clipboard (provide internal clipboard content to plugins)
288            snapshot.clipboard = self.clipboard.get_internal().to_string();
289
290            // Update working directory (for spawning processes in correct directory)
291            snapshot.working_dir = self.working_dir.clone();
292            snapshot.authority_label = self.authority.display_label.clone();
293
294            // Update LSP diagnostics: Arc refcount bump; no clone.
295            snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
296
297            // Update LSP folding ranges: Arc refcount bump; no clone.
298            snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
299
300            // Update config. Reserialize only when the underlying
301            // `Arc<Config>` pointer has actually moved since the last
302            // refresh — `Arc::ptr_eq` vs `config_snapshot_anchor` is a
303            // sound cache key because the anchor keeps `self.config`'s
304            // strong count at ≥ 2, forcing every `Arc::make_mut` on the
305            // editor side to CoW into a new allocation. On idle (no
306            // config mutation), this branch is skipped entirely and the
307            // snapshot update is a refcount bump.
308            if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
309                let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
310                self.config_cached_json = Arc::new(json);
311                self.config_snapshot_anchor = Arc::clone(&self.config);
312            }
313            snapshot.config = Arc::clone(&self.config_cached_json);
314
315            // Update user config (cached raw file contents, not merged with defaults).
316            // This allows plugins to distinguish between user-set and default values.
317            // Arc refcount bump; no clone.
318            snapshot.user_config = Arc::clone(&self.user_config_raw);
319
320            // Update editor mode (for vi mode and other modal editing)
321            snapshot.editor_mode = self.editor_mode.clone();
322
323            // Update plugin global states from Rust-side store.
324            // Merge using or_insert to preserve JS-side write-through entries.
325            for (plugin_name, state_map) in &self.plugin_global_state {
326                let entry = snapshot
327                    .plugin_global_states
328                    .entry(plugin_name.clone())
329                    .or_default();
330                for (key, value) in state_map {
331                    entry.entry(key.clone()).or_insert_with(|| value.clone());
332                }
333            }
334
335            // Update plugin view states from active split's BufferViewState.plugin_state.
336            // If the active split changed, fully repopulate. Otherwise, merge using
337            // or_insert to preserve JS-side write-through entries that haven't
338            // round-tripped through the command channel yet.
339            let active_split_id = self.split_manager.active_split().0 .0;
340            let split_changed = snapshot.plugin_view_states_split != active_split_id;
341            if split_changed {
342                snapshot.plugin_view_states.clear();
343                snapshot.plugin_view_states_split = active_split_id;
344            }
345
346            // Clean up entries for buffers that are no longer open
347            {
348                let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
349                snapshot
350                    .plugin_view_states
351                    .retain(|bid, _| open_bids.contains(bid));
352            }
353
354            // Merge from Rust-side plugin_state (source of truth for persisted state)
355            if let Some(active_vs) = self
356                .split_view_states
357                .get(&self.split_manager.active_split())
358            {
359                for (buffer_id, buf_state) in &active_vs.keyed_states {
360                    if !buf_state.plugin_state.is_empty() {
361                        let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
362                        for (key, value) in &buf_state.plugin_state {
363                            // Use or_insert to preserve JS write-through values
364                            entry.entry(key.clone()).or_insert_with(|| value.clone());
365                        }
366                    }
367                }
368            }
369        }
370    }
371
372    /// Handle a plugin command - dispatches to specialized handlers in plugin_commands module
373    pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
374        match command {
375            // ==================== Text Editing Commands ====================
376            PluginCommand::InsertText {
377                buffer_id,
378                position,
379                text,
380            } => {
381                self.handle_insert_text(buffer_id, position, text);
382            }
383            PluginCommand::DeleteRange { buffer_id, range } => {
384                self.handle_delete_range(buffer_id, range);
385            }
386            PluginCommand::InsertAtCursor { text } => {
387                self.handle_insert_at_cursor(text);
388            }
389            PluginCommand::DeleteSelection => {
390                self.handle_delete_selection();
391            }
392
393            // ==================== Overlay Commands ====================
394            PluginCommand::AddOverlay {
395                buffer_id,
396                namespace,
397                range,
398                options,
399            } => {
400                self.handle_add_overlay(buffer_id, namespace, range, options);
401            }
402            PluginCommand::RemoveOverlay { buffer_id, handle } => {
403                self.handle_remove_overlay(buffer_id, handle);
404            }
405            PluginCommand::ClearAllOverlays { buffer_id } => {
406                self.handle_clear_all_overlays(buffer_id);
407            }
408            PluginCommand::ClearNamespace {
409                buffer_id,
410                namespace,
411            } => {
412                self.handle_clear_namespace(buffer_id, namespace);
413            }
414            PluginCommand::ClearOverlaysInRange {
415                buffer_id,
416                start,
417                end,
418            } => {
419                self.handle_clear_overlays_in_range(buffer_id, start, end);
420            }
421
422            // ==================== Virtual Text Commands ====================
423            PluginCommand::AddVirtualText {
424                buffer_id,
425                virtual_text_id,
426                position,
427                text,
428                color,
429                use_bg,
430                before,
431            } => {
432                self.handle_add_virtual_text(
433                    buffer_id,
434                    virtual_text_id,
435                    position,
436                    text,
437                    color,
438                    use_bg,
439                    before,
440                );
441            }
442            PluginCommand::AddVirtualTextStyled {
443                buffer_id,
444                virtual_text_id,
445                position,
446                text,
447                fg,
448                bg,
449                bold,
450                italic,
451                before,
452            } => {
453                self.handle_add_virtual_text_styled(
454                    buffer_id,
455                    virtual_text_id,
456                    position,
457                    text,
458                    fg,
459                    bg,
460                    bold,
461                    italic,
462                    before,
463                );
464            }
465            PluginCommand::RemoveVirtualText {
466                buffer_id,
467                virtual_text_id,
468            } => {
469                self.handle_remove_virtual_text(buffer_id, virtual_text_id);
470            }
471            PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
472                self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
473            }
474            PluginCommand::ClearVirtualTexts { buffer_id } => {
475                self.handle_clear_virtual_texts(buffer_id);
476            }
477            PluginCommand::AddVirtualLine {
478                buffer_id,
479                position,
480                text,
481                fg_color,
482                bg_color,
483                above,
484                namespace,
485                priority,
486            } => {
487                self.handle_add_virtual_line(
488                    buffer_id, position, text, fg_color, bg_color, above, namespace, priority,
489                );
490            }
491            PluginCommand::ClearVirtualTextNamespace {
492                buffer_id,
493                namespace,
494            } => {
495                self.handle_clear_virtual_text_namespace(buffer_id, namespace);
496            }
497
498            // ==================== Conceal Commands ====================
499            PluginCommand::AddConceal {
500                buffer_id,
501                namespace,
502                start,
503                end,
504                replacement,
505            } => {
506                self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
507            }
508            PluginCommand::ClearConcealNamespace {
509                buffer_id,
510                namespace,
511            } => {
512                self.handle_clear_conceal_namespace(buffer_id, namespace);
513            }
514            PluginCommand::ClearConcealsInRange {
515                buffer_id,
516                start,
517                end,
518            } => {
519                self.handle_clear_conceals_in_range(buffer_id, start, end);
520            }
521
522            PluginCommand::AddFold {
523                buffer_id,
524                start,
525                end,
526                placeholder,
527            } => {
528                self.handle_add_fold(buffer_id, start, end, placeholder);
529            }
530            PluginCommand::ClearFolds { buffer_id } => {
531                self.handle_clear_folds(buffer_id);
532            }
533
534            // ==================== Soft Break Commands ====================
535            PluginCommand::AddSoftBreak {
536                buffer_id,
537                namespace,
538                position,
539                indent,
540            } => {
541                self.handle_add_soft_break(buffer_id, namespace, position, indent);
542            }
543            PluginCommand::ClearSoftBreakNamespace {
544                buffer_id,
545                namespace,
546            } => {
547                self.handle_clear_soft_break_namespace(buffer_id, namespace);
548            }
549            PluginCommand::ClearSoftBreaksInRange {
550                buffer_id,
551                start,
552                end,
553            } => {
554                self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
555            }
556
557            // ==================== Menu Commands ====================
558            PluginCommand::AddMenuItem {
559                menu_label,
560                item,
561                position,
562            } => {
563                self.handle_add_menu_item(menu_label, item, position);
564            }
565            PluginCommand::AddMenu { menu, position } => {
566                self.handle_add_menu(menu, position);
567            }
568            PluginCommand::RemoveMenuItem {
569                menu_label,
570                item_label,
571            } => {
572                self.handle_remove_menu_item(menu_label, item_label);
573            }
574            PluginCommand::RemoveMenu { menu_label } => {
575                self.handle_remove_menu(menu_label);
576            }
577
578            // ==================== Split Commands ====================
579            PluginCommand::FocusSplit { split_id } => {
580                self.handle_focus_split(split_id);
581            }
582            PluginCommand::SetSplitBuffer {
583                split_id,
584                buffer_id,
585            } => {
586                self.handle_set_split_buffer(split_id, buffer_id);
587            }
588            PluginCommand::SetSplitScroll { split_id, top_byte } => {
589                self.handle_set_split_scroll(split_id, top_byte);
590            }
591            PluginCommand::RequestHighlights {
592                buffer_id,
593                range,
594                request_id,
595            } => {
596                self.handle_request_highlights(buffer_id, range, request_id);
597            }
598            PluginCommand::CloseSplit { split_id } => {
599                self.handle_close_split(split_id);
600            }
601            PluginCommand::SetSplitRatio { split_id, ratio } => {
602                self.handle_set_split_ratio(split_id, ratio);
603            }
604            PluginCommand::SetSplitLabel { split_id, label } => {
605                self.split_manager.set_label(LeafId(split_id), label);
606            }
607            PluginCommand::ClearSplitLabel { split_id } => {
608                self.split_manager.clear_label(split_id);
609            }
610            PluginCommand::GetSplitByLabel { label, request_id } => {
611                self.handle_get_split_by_label(label, request_id);
612            }
613            PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
614                self.handle_distribute_splits_evenly();
615            }
616            PluginCommand::SetBufferCursor {
617                buffer_id,
618                position,
619            } => {
620                self.handle_set_buffer_cursor(buffer_id, position);
621            }
622            PluginCommand::SetBufferShowCursors { buffer_id, show } => {
623                self.handle_set_buffer_show_cursors(buffer_id, show);
624            }
625
626            // ==================== View/Layout Commands ====================
627            PluginCommand::SetLayoutHints {
628                buffer_id,
629                split_id,
630                range: _,
631                hints,
632            } => {
633                self.handle_set_layout_hints(buffer_id, split_id, hints);
634            }
635            PluginCommand::SetLineNumbers { buffer_id, enabled } => {
636                self.handle_set_line_numbers(buffer_id, enabled);
637            }
638            PluginCommand::SetViewMode { buffer_id, mode } => {
639                self.handle_set_view_mode(buffer_id, &mode);
640            }
641            PluginCommand::SetLineWrap {
642                buffer_id,
643                split_id,
644                enabled,
645            } => {
646                self.handle_set_line_wrap(buffer_id, split_id, enabled);
647            }
648            PluginCommand::SubmitViewTransform {
649                buffer_id,
650                split_id,
651                payload,
652            } => {
653                self.handle_submit_view_transform(buffer_id, split_id, payload);
654            }
655            PluginCommand::ClearViewTransform {
656                buffer_id: _,
657                split_id,
658            } => {
659                self.handle_clear_view_transform(split_id);
660            }
661            PluginCommand::SetViewState {
662                buffer_id,
663                key,
664                value,
665            } => {
666                self.handle_set_view_state(buffer_id, key, value);
667            }
668            PluginCommand::SetGlobalState {
669                plugin_name,
670                key,
671                value,
672            } => {
673                self.handle_set_global_state(plugin_name, key, value);
674            }
675            PluginCommand::RefreshLines { buffer_id } => {
676                self.handle_refresh_lines(buffer_id);
677            }
678            PluginCommand::RefreshAllLines => {
679                self.handle_refresh_all_lines();
680            }
681            PluginCommand::HookCompleted { .. } => {
682                // Sentinel processed in render loop; no-op if encountered elsewhere.
683            }
684            PluginCommand::SetLineIndicator {
685                buffer_id,
686                line,
687                namespace,
688                symbol,
689                color,
690                priority,
691            } => {
692                self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
693            }
694            PluginCommand::SetLineIndicators {
695                buffer_id,
696                lines,
697                namespace,
698                symbol,
699                color,
700                priority,
701            } => {
702                self.handle_set_line_indicators(
703                    buffer_id, lines, namespace, symbol, color, priority,
704                );
705            }
706            PluginCommand::ClearLineIndicators {
707                buffer_id,
708                namespace,
709            } => {
710                self.handle_clear_line_indicators(buffer_id, namespace);
711            }
712            PluginCommand::SetFileExplorerDecorations {
713                namespace,
714                decorations,
715            } => {
716                self.handle_set_file_explorer_decorations(namespace, decorations);
717            }
718            PluginCommand::ClearFileExplorerDecorations { namespace } => {
719                self.handle_clear_file_explorer_decorations(&namespace);
720            }
721
722            // ==================== Status/Prompt Commands ====================
723            PluginCommand::SetStatus { message } => {
724                self.handle_set_status(message);
725            }
726            PluginCommand::ApplyTheme { theme_name } => {
727                self.apply_theme(&theme_name);
728            }
729            PluginCommand::OverrideThemeColors { overrides } => {
730                self.handle_override_theme_colors(overrides);
731            }
732            PluginCommand::ReloadConfig => {
733                self.reload_config();
734            }
735            PluginCommand::SetSetting { path, value, .. } => {
736                self.handle_set_setting(path, value);
737            }
738            PluginCommand::ReloadThemes { apply_theme } => {
739                self.reload_themes();
740                if let Some(theme_name) = apply_theme {
741                    self.apply_theme(&theme_name);
742                }
743            }
744            PluginCommand::RegisterGrammar {
745                language,
746                grammar_path,
747                extensions,
748            } => {
749                self.handle_register_grammar(language, grammar_path, extensions);
750            }
751            PluginCommand::RegisterLanguageConfig { language, config } => {
752                self.handle_register_language_config(language, config);
753            }
754            PluginCommand::RegisterLspServer { language, config } => {
755                self.handle_register_lsp_server(language, config);
756            }
757            PluginCommand::ReloadGrammars { callback_id } => {
758                self.handle_reload_grammars(callback_id);
759            }
760            PluginCommand::StartPrompt { label, prompt_type } => {
761                self.handle_start_prompt(label, prompt_type);
762            }
763            PluginCommand::StartPromptWithInitial {
764                label,
765                prompt_type,
766                initial_value,
767            } => {
768                self.handle_start_prompt_with_initial(label, prompt_type, initial_value);
769            }
770            PluginCommand::StartPromptAsync {
771                label,
772                initial_value,
773                callback_id,
774            } => {
775                self.handle_start_prompt_async(label, initial_value, callback_id);
776            }
777            PluginCommand::AwaitNextKey { callback_id } => {
778                self.handle_await_next_key(callback_id);
779            }
780            PluginCommand::SetKeyCaptureActive { active } => {
781                self.key_capture_active = active;
782                if !active {
783                    // Capture window closed; any leftover queued keys
784                    // were intended for the plugin and should not now
785                    // leak into the editor's normal dispatch.
786                    self.pending_key_capture_buffer.clear();
787                }
788            }
789            PluginCommand::SetPromptSuggestions { suggestions } => {
790                self.handle_set_prompt_suggestions(suggestions);
791            }
792            PluginCommand::SetPromptInputSync { sync } => {
793                if let Some(prompt) = &mut self.prompt {
794                    prompt.sync_input_on_navigate = sync;
795                }
796            }
797
798            // ==================== Command/Mode Registration ====================
799            PluginCommand::RegisterCommand { command } => {
800                self.handle_register_command(command);
801            }
802            PluginCommand::UnregisterCommand { name } => {
803                self.handle_unregister_command(name);
804            }
805            PluginCommand::DefineMode {
806                name,
807                bindings,
808                read_only,
809                allow_text_input,
810                inherit_normal_bindings,
811                plugin_name,
812            } => {
813                self.handle_define_mode(
814                    name,
815                    bindings,
816                    read_only,
817                    allow_text_input,
818                    inherit_normal_bindings,
819                    plugin_name,
820                );
821            }
822
823            // ==================== File/Navigation Commands ====================
824            PluginCommand::OpenFileInBackground { path } => {
825                self.handle_open_file_in_background(path);
826            }
827            PluginCommand::OpenFileAtLocation { path, line, column } => {
828                return self.handle_open_file_at_location(path, line, column);
829            }
830            PluginCommand::OpenFileInSplit {
831                split_id,
832                path,
833                line,
834                column,
835            } => {
836                return self.handle_open_file_in_split(split_id, path, line, column);
837            }
838            PluginCommand::ShowBuffer { buffer_id } => {
839                self.handle_show_buffer(buffer_id);
840            }
841            PluginCommand::CloseBuffer { buffer_id } => {
842                self.handle_close_buffer(buffer_id);
843            }
844
845            // ==================== Animation Commands ====================
846            PluginCommand::StartAnimationArea { id, rect, kind } => {
847                self.handle_start_animation_area(id, rect, kind);
848            }
849            PluginCommand::StartAnimationVirtualBuffer {
850                id,
851                buffer_id,
852                kind,
853            } => {
854                self.handle_start_animation_virtual_buffer(id, buffer_id, kind);
855            }
856            PluginCommand::CancelAnimation { id } => {
857                self.animations
858                    .cancel(crate::view::animation::AnimationId::from_raw(id));
859            }
860
861            // ==================== LSP Commands ====================
862            PluginCommand::SendLspRequest {
863                language,
864                method,
865                params,
866                request_id,
867            } => {
868                self.handle_send_lsp_request(language, method, params, request_id);
869            }
870
871            // ==================== Clipboard Commands ====================
872            PluginCommand::SetClipboard { text } => {
873                self.handle_set_clipboard(text);
874            }
875
876            // ==================== Async Plugin Commands ====================
877            PluginCommand::SpawnProcess {
878                command,
879                args,
880                cwd,
881                callback_id,
882            } => {
883                self.handle_spawn_process(command, args, cwd, callback_id);
884            }
885
886            PluginCommand::SpawnHostProcess {
887                command,
888                args,
889                cwd,
890                callback_id,
891            } => {
892                self.handle_spawn_host_process(command, args, cwd, callback_id);
893            }
894
895            PluginCommand::KillHostProcess { process_id } => {
896                self.handle_kill_host_process(process_id);
897            }
898
899            PluginCommand::SetAuthority { payload } => {
900                self.handle_set_authority(payload);
901            }
902
903            PluginCommand::ClearAuthority => {
904                tracing::info!("Plugin cleared authority; restoring local");
905                self.clear_authority();
906            }
907
908            PluginCommand::SetRemoteIndicatorState { state } => {
909                self.handle_set_remote_indicator_state(state);
910            }
911
912            PluginCommand::ClearRemoteIndicatorState => {
913                self.remote_indicator_override = None;
914            }
915
916            PluginCommand::SpawnProcessWait {
917                process_id,
918                callback_id,
919            } => {
920                self.handle_spawn_process_wait(process_id, callback_id);
921            }
922
923            PluginCommand::Delay {
924                callback_id,
925                duration_ms,
926            } => {
927                self.handle_delay(callback_id, duration_ms);
928            }
929
930            PluginCommand::SpawnBackgroundProcess {
931                process_id,
932                command,
933                args,
934                cwd,
935                callback_id,
936            } => {
937                self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
938            }
939
940            PluginCommand::KillBackgroundProcess { process_id } => {
941                self.handle_kill_background_process(process_id);
942            }
943
944            // ==================== Virtual Buffer Commands (complex, kept inline) ====================
945            PluginCommand::CreateVirtualBuffer {
946                name,
947                mode,
948                read_only,
949            } => {
950                self.handle_create_virtual_buffer(name, mode, read_only);
951            }
952            PluginCommand::CreateVirtualBufferWithContent {
953                name,
954                mode,
955                read_only,
956                entries,
957                show_line_numbers,
958                show_cursors,
959                editing_disabled,
960                hidden_from_tabs,
961                request_id,
962            } => {
963                self.handle_create_virtual_buffer_with_content(
964                    name,
965                    mode,
966                    read_only,
967                    entries,
968                    show_line_numbers,
969                    show_cursors,
970                    editing_disabled,
971                    hidden_from_tabs,
972                    request_id,
973                );
974            }
975            PluginCommand::CreateVirtualBufferInSplit {
976                name,
977                mode,
978                read_only,
979                entries,
980                ratio,
981                direction,
982                panel_id,
983                show_line_numbers,
984                show_cursors,
985                editing_disabled,
986                line_wrap,
987                before,
988                request_id,
989            } => {
990                self.handle_create_virtual_buffer_in_split(
991                    name,
992                    mode,
993                    read_only,
994                    entries,
995                    ratio,
996                    direction,
997                    panel_id,
998                    show_line_numbers,
999                    show_cursors,
1000                    editing_disabled,
1001                    line_wrap,
1002                    before,
1003                    request_id,
1004                );
1005            }
1006            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1007                self.handle_set_virtual_buffer_content(buffer_id, entries);
1008            }
1009            PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1010                self.handle_get_text_properties_at_cursor(buffer_id);
1011            }
1012            PluginCommand::CreateVirtualBufferInExistingSplit {
1013                name,
1014                mode,
1015                read_only,
1016                entries,
1017                split_id,
1018                show_line_numbers,
1019                show_cursors,
1020                editing_disabled,
1021                line_wrap,
1022                request_id,
1023            } => {
1024                self.handle_create_virtual_buffer_in_existing_split(
1025                    name,
1026                    mode,
1027                    read_only,
1028                    entries,
1029                    split_id,
1030                    show_line_numbers,
1031                    show_cursors,
1032                    editing_disabled,
1033                    line_wrap,
1034                    request_id,
1035                );
1036            }
1037
1038            // ==================== Context Commands ====================
1039            PluginCommand::SetContext { name, active } => {
1040                self.handle_set_context(name, active);
1041            }
1042
1043            // ==================== Review Diff Commands ====================
1044            PluginCommand::SetReviewDiffHunks { hunks } => {
1045                self.review_hunks = hunks;
1046                tracing::debug!("Set {} review hunks", self.review_hunks.len());
1047            }
1048
1049            // ==================== Vi Mode Commands ====================
1050            PluginCommand::ExecuteAction { action_name } => {
1051                self.handle_execute_action(action_name);
1052            }
1053            PluginCommand::ExecuteActions { actions } => {
1054                self.handle_execute_actions(actions);
1055            }
1056            PluginCommand::GetBufferText {
1057                buffer_id,
1058                start,
1059                end,
1060                request_id,
1061            } => {
1062                self.handle_get_buffer_text(buffer_id, start, end, request_id);
1063            }
1064            PluginCommand::GetLineStartPosition {
1065                buffer_id,
1066                line,
1067                request_id,
1068            } => {
1069                self.handle_get_line_start_position(buffer_id, line, request_id);
1070            }
1071            PluginCommand::GetLineEndPosition {
1072                buffer_id,
1073                line,
1074                request_id,
1075            } => {
1076                self.handle_get_line_end_position(buffer_id, line, request_id);
1077            }
1078            PluginCommand::GetBufferLineCount {
1079                buffer_id,
1080                request_id,
1081            } => {
1082                self.handle_get_buffer_line_count(buffer_id, request_id);
1083            }
1084            PluginCommand::ScrollToLineCenter {
1085                split_id,
1086                buffer_id,
1087                line,
1088            } => {
1089                self.handle_scroll_to_line_center(split_id, buffer_id, line);
1090            }
1091            PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1092                self.handle_scroll_buffer_to_line(buffer_id, line);
1093            }
1094            PluginCommand::SetEditorMode { mode } => {
1095                self.handle_set_editor_mode(mode);
1096            }
1097
1098            // ==================== LSP Helper Commands ====================
1099            PluginCommand::ShowActionPopup {
1100                popup_id,
1101                title,
1102                message,
1103                actions,
1104            } => {
1105                self.handle_show_action_popup(popup_id, title, message, actions);
1106            }
1107
1108            PluginCommand::DisableLspForLanguage { language } => {
1109                self.handle_disable_lsp_for_language(language);
1110            }
1111
1112            PluginCommand::RestartLspForLanguage { language } => {
1113                self.handle_restart_lsp_for_language(language);
1114            }
1115
1116            PluginCommand::SetLspRootUri { language, uri } => {
1117                self.handle_set_lsp_root_uri(language, uri);
1118            }
1119
1120            // ==================== Scroll Sync Commands ====================
1121            PluginCommand::CreateScrollSyncGroup {
1122                group_id,
1123                left_split,
1124                right_split,
1125            } => {
1126                self.handle_create_scroll_sync_group(group_id, left_split, right_split);
1127            }
1128            PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1129                self.handle_set_scroll_sync_anchors(group_id, anchors);
1130            }
1131            PluginCommand::RemoveScrollSyncGroup { group_id } => {
1132                self.handle_remove_scroll_sync_group(group_id);
1133            }
1134
1135            // ==================== Composite Buffer Commands ====================
1136            PluginCommand::CreateCompositeBuffer {
1137                name,
1138                mode,
1139                layout,
1140                sources,
1141                hunks,
1142                initial_focus_hunk,
1143                request_id,
1144            } => {
1145                self.handle_create_composite_buffer(
1146                    name,
1147                    mode,
1148                    layout,
1149                    sources,
1150                    hunks,
1151                    initial_focus_hunk,
1152                    request_id,
1153                );
1154            }
1155            PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1156                self.handle_update_composite_alignment(buffer_id, hunks);
1157            }
1158            PluginCommand::CloseCompositeBuffer { buffer_id } => {
1159                self.close_composite_buffer(buffer_id);
1160            }
1161            PluginCommand::FlushLayout => {
1162                self.flush_layout();
1163            }
1164            PluginCommand::CompositeNextHunk { buffer_id } => {
1165                let split_id = self.split_manager.active_split();
1166                self.composite_next_hunk(split_id, buffer_id);
1167            }
1168            PluginCommand::CompositePrevHunk { buffer_id } => {
1169                let split_id = self.split_manager.active_split();
1170                self.composite_prev_hunk(split_id, buffer_id);
1171            }
1172
1173            // ==================== Buffer Groups ====================
1174            PluginCommand::CreateBufferGroup {
1175                name,
1176                mode,
1177                layout_json,
1178                request_id,
1179            } => {
1180                self.handle_create_buffer_group(name, mode, layout_json, request_id);
1181            }
1182            PluginCommand::SetPanelContent {
1183                group_id,
1184                panel_name,
1185                entries,
1186            } => {
1187                self.set_panel_content(group_id, panel_name, entries);
1188            }
1189            PluginCommand::CloseBufferGroup { group_id } => {
1190                self.close_buffer_group(group_id);
1191            }
1192            PluginCommand::FocusPanel {
1193                group_id,
1194                panel_name,
1195            } => {
1196                self.focus_panel(group_id, panel_name);
1197            }
1198
1199            // ==================== File Operations ====================
1200            PluginCommand::SaveBufferToPath { buffer_id, path } => {
1201                self.handle_save_buffer_to_path(buffer_id, path);
1202            }
1203
1204            // ==================== Plugin Management ====================
1205            #[cfg(feature = "plugins")]
1206            PluginCommand::LoadPlugin { path, callback_id } => {
1207                self.handle_load_plugin(path, callback_id);
1208            }
1209            #[cfg(feature = "plugins")]
1210            PluginCommand::UnloadPlugin { name, callback_id } => {
1211                self.handle_unload_plugin(name, callback_id);
1212            }
1213            #[cfg(feature = "plugins")]
1214            PluginCommand::ReloadPlugin { name, callback_id } => {
1215                self.handle_reload_plugin(name, callback_id);
1216            }
1217            #[cfg(feature = "plugins")]
1218            PluginCommand::ListPlugins { callback_id } => {
1219                self.handle_list_plugins(callback_id);
1220            }
1221            // When plugins feature is disabled, these commands are no-ops
1222            #[cfg(not(feature = "plugins"))]
1223            PluginCommand::LoadPlugin { .. }
1224            | PluginCommand::UnloadPlugin { .. }
1225            | PluginCommand::ReloadPlugin { .. }
1226            | PluginCommand::ListPlugins { .. } => {
1227                tracing::warn!("Plugin management commands require the 'plugins' feature");
1228            }
1229
1230            // ==================== Terminal Commands ====================
1231            PluginCommand::CreateTerminal {
1232                cwd,
1233                direction,
1234                ratio,
1235                focus,
1236                persistent,
1237                request_id,
1238            } => {
1239                self.handle_create_terminal(cwd, direction, ratio, focus, persistent, request_id);
1240            }
1241
1242            PluginCommand::SendTerminalInput { terminal_id, data } => {
1243                self.handle_send_terminal_input(terminal_id, data);
1244            }
1245
1246            PluginCommand::CloseTerminal { terminal_id } => {
1247                self.handle_close_terminal(terminal_id);
1248            }
1249
1250            PluginCommand::GrepProject {
1251                pattern,
1252                fixed_string,
1253                case_sensitive,
1254                max_results,
1255                whole_words,
1256                callback_id,
1257            } => {
1258                self.handle_grep_project(
1259                    pattern,
1260                    fixed_string,
1261                    case_sensitive,
1262                    max_results,
1263                    whole_words,
1264                    callback_id,
1265                );
1266            }
1267
1268            PluginCommand::GrepProjectStreaming {
1269                pattern,
1270                fixed_string,
1271                case_sensitive,
1272                max_results,
1273                whole_words,
1274                search_id,
1275                callback_id,
1276            } => {
1277                self.handle_grep_project_streaming(
1278                    pattern,
1279                    fixed_string,
1280                    case_sensitive,
1281                    max_results,
1282                    whole_words,
1283                    search_id,
1284                    callback_id,
1285                );
1286            }
1287
1288            PluginCommand::ReplaceInBuffer {
1289                file_path,
1290                matches,
1291                replacement,
1292                callback_id,
1293            } => {
1294                self.handle_replace_in_buffer(file_path, matches, replacement, callback_id);
1295            }
1296        }
1297        Ok(())
1298    }
1299
1300    /// Save a buffer to a specific file path (for :w filename)
1301    fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1302        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1303            // Save to the specified path
1304            match state.buffer.save_to_file(&path) {
1305                Ok(()) => {
1306                    // save_to_file already updates file_path internally via finalize_save
1307                    // Run on-save actions (formatting, etc.)
1308                    if let Err(e) = self.finalize_save(Some(path)) {
1309                        tracing::warn!("Failed to finalize save: {}", e);
1310                    }
1311                    tracing::debug!("Saved buffer {:?} to path", buffer_id);
1312                }
1313                Err(e) => {
1314                    self.handle_set_status(format!("Error saving: {}", e));
1315                    tracing::error!("Failed to save buffer to path: {}", e);
1316                }
1317            }
1318        } else {
1319            self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1320            tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1321        }
1322    }
1323
1324    /// Load a plugin from a file path
1325    #[cfg(feature = "plugins")]
1326    fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1327        match self.plugin_manager.load_plugin(&path) {
1328            Ok(()) => {
1329                tracing::info!("Loaded plugin from {:?}", path);
1330                self.plugin_manager
1331                    .resolve_callback(callback_id, "true".to_string());
1332            }
1333            Err(e) => {
1334                tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1335                self.plugin_manager
1336                    .reject_callback(callback_id, format!("{}", e));
1337            }
1338        }
1339    }
1340
1341    /// Unload a plugin by name
1342    #[cfg(feature = "plugins")]
1343    fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1344        match self.plugin_manager.unload_plugin(&name) {
1345            Ok(()) => {
1346                tracing::info!("Unloaded plugin: {}", name);
1347                self.plugin_manager
1348                    .resolve_callback(callback_id, "true".to_string());
1349            }
1350            Err(e) => {
1351                tracing::error!("Failed to unload plugin '{}': {}", name, e);
1352                self.plugin_manager
1353                    .reject_callback(callback_id, format!("{}", e));
1354            }
1355        }
1356    }
1357
1358    /// Reload a plugin by name
1359    #[cfg(feature = "plugins")]
1360    fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1361        match self.plugin_manager.reload_plugin(&name) {
1362            Ok(()) => {
1363                tracing::info!("Reloaded plugin: {}", name);
1364                self.plugin_manager
1365                    .resolve_callback(callback_id, "true".to_string());
1366            }
1367            Err(e) => {
1368                tracing::error!("Failed to reload plugin '{}': {}", name, e);
1369                self.plugin_manager
1370                    .reject_callback(callback_id, format!("{}", e));
1371            }
1372        }
1373    }
1374
1375    /// List all loaded plugins
1376    #[cfg(feature = "plugins")]
1377    fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
1378        let plugins = self.plugin_manager.list_plugins();
1379        // Serialize to JSON array of { name, path, enabled }
1380        let json_array: Vec<serde_json::Value> = plugins
1381            .iter()
1382            .map(|p| {
1383                serde_json::json!({
1384                    "name": p.name,
1385                    "path": p.path.to_string_lossy(),
1386                    "enabled": p.enabled
1387                })
1388            })
1389            .collect();
1390        let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
1391        self.plugin_manager.resolve_callback(callback_id, json_str);
1392    }
1393
1394    /// Execute an editor action by name (for vi mode plugin)
1395    fn handle_execute_action(&mut self, action_name: String) {
1396        use crate::input::keybindings::Action;
1397        use std::collections::HashMap;
1398
1399        // Parse the action name into an Action enum
1400        if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
1401            // Execute the action
1402            if let Err(e) = self.handle_action(action) {
1403                tracing::warn!("Failed to execute action '{}': {}", action_name, e);
1404            } else {
1405                tracing::debug!("Executed action: {}", action_name);
1406            }
1407        } else {
1408            tracing::warn!("Unknown action: {}", action_name);
1409        }
1410    }
1411
1412    /// Execute multiple actions in sequence, each with an optional repeat count
1413    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1414    fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
1415        use crate::input::keybindings::Action;
1416        use std::collections::HashMap;
1417
1418        for action_spec in actions {
1419            if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
1420                // Execute the action `count` times
1421                for _ in 0..action_spec.count {
1422                    if let Err(e) = self.handle_action(action.clone()) {
1423                        tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
1424                        return; // Stop on first error
1425                    }
1426                }
1427                tracing::debug!(
1428                    "Executed action '{}' {} time(s)",
1429                    action_spec.action,
1430                    action_spec.count
1431                );
1432            } else {
1433                tracing::warn!("Unknown action: {}", action_spec.action);
1434                return; // Stop on unknown action
1435            }
1436        }
1437    }
1438
1439    /// Get text from a buffer range (for vi mode yank operations)
1440    fn handle_get_buffer_text(
1441        &mut self,
1442        buffer_id: BufferId,
1443        start: usize,
1444        end: usize,
1445        request_id: u64,
1446    ) {
1447        let result = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1448            // Get text from the buffer using the mutable get_text_range method
1449            let len = state.buffer.len();
1450            if start <= end && end <= len {
1451                Ok(state.get_text_range(start, end))
1452            } else {
1453                Err(format!(
1454                    "Invalid range {}..{} for buffer of length {}",
1455                    start, end, len
1456                ))
1457            }
1458        } else {
1459            Err(format!("Buffer {:?} not found", buffer_id))
1460        };
1461
1462        // Resolve the JavaScript Promise callback directly
1463        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1464        match result {
1465            Ok(text) => {
1466                // Serialize text as JSON string
1467                let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
1468                self.plugin_manager.resolve_callback(callback_id, json);
1469            }
1470            Err(error) => {
1471                self.plugin_manager.reject_callback(callback_id, error);
1472            }
1473        }
1474    }
1475
1476    /// Set the global editor mode (for vi mode)
1477    fn handle_set_editor_mode(&mut self, mode: Option<String>) {
1478        self.editor_mode = mode.clone();
1479        tracing::debug!("Set editor mode: {:?}", mode);
1480    }
1481
1482    /// Normalize a plugin-supplied `BufferId`: treat id 0 as "use the active buffer".
1483    fn resolve_buffer_id(&self, buffer_id: BufferId) -> BufferId {
1484        if buffer_id.0 == 0 {
1485            self.active_buffer()
1486        } else {
1487            buffer_id
1488        }
1489    }
1490
1491    /// Serialize `value` as JSON and resolve `request_id` as a JS Promise callback.
1492    fn resolve_json_callback<T: serde::Serialize>(&mut self, request_id: u64, value: T) {
1493        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1494        let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1495        self.plugin_manager.resolve_callback(callback_id, json);
1496    }
1497
1498    /// Get the byte offset of the start of a line in the active buffer
1499    fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1500        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1501        let result = self.buffers.get_mut(&actual_buffer_id).and_then(|state| {
1502            let len = state.buffer.len();
1503            let content = state.get_text_range(0, len);
1504            buffer_line_byte_offset(&content, len, line as usize, false)
1505        });
1506        self.resolve_json_callback(request_id, result);
1507    }
1508
1509    /// Get the byte offset of the end of a line (position of its terminating newline,
1510    /// or `buffer_len` for the last line without a trailing newline).
1511    fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1512        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1513        let result = self.buffers.get_mut(&actual_buffer_id).and_then(|state| {
1514            let len = state.buffer.len();
1515            let content = state.get_text_range(0, len);
1516            buffer_line_byte_offset(&content, len, line as usize, true)
1517        });
1518        self.resolve_json_callback(request_id, result);
1519    }
1520
1521    /// Get the total number of lines in a buffer
1522    fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
1523        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1524
1525        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
1526            let buffer_len = state.buffer.len();
1527            let content = state.get_text_range(0, buffer_len);
1528
1529            // Count lines (number of newlines + 1, unless empty)
1530            if content.is_empty() {
1531                Some(1) // Empty buffer has 1 line
1532            } else {
1533                let newline_count = content.chars().filter(|&c| c == '\n').count();
1534                // If file ends with newline, don't count extra line
1535                let ends_with_newline = content.ends_with('\n');
1536                if ends_with_newline {
1537                    Some(newline_count)
1538                } else {
1539                    Some(newline_count + 1)
1540                }
1541            }
1542        } else {
1543            None
1544        };
1545
1546        self.resolve_json_callback(request_id, result);
1547    }
1548
1549    /// Scroll a split to center a specific line in the viewport
1550    fn handle_scroll_to_line_center(
1551        &mut self,
1552        split_id: SplitId,
1553        buffer_id: BufferId,
1554        line: usize,
1555    ) {
1556        let actual_split_id = if split_id.0 == 0 {
1557            self.split_manager.active_split()
1558        } else {
1559            LeafId(split_id)
1560        };
1561        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1562
1563        // Get viewport height
1564        let viewport_height = if let Some(view_state) = self.split_view_states.get(&actual_split_id)
1565        {
1566            view_state.viewport.height as usize
1567        } else {
1568            return;
1569        };
1570
1571        // Calculate the target line to scroll to (center the requested line)
1572        let lines_above = viewport_height / 2;
1573        let target_line = line.saturating_sub(lines_above);
1574
1575        // Get the buffer and scroll
1576        if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
1577            let buffer = &mut state.buffer;
1578            if let Some(view_state) = self.split_view_states.get_mut(&actual_split_id) {
1579                view_state.viewport.scroll_to(buffer, target_line);
1580                // Mark to skip ensure_visible on next render so the scroll isn't undone
1581                view_state.viewport.set_skip_ensure_visible();
1582            }
1583        }
1584    }
1585
1586    /// Scroll every split whose active buffer is `buffer_id` so that
1587    /// `line` is within the viewport. Used by plugin panels (buffer
1588    /// groups) whose plugin-side "selected row" doesn't drive the
1589    /// buffer cursor — after updating the selection, the plugin calls
1590    /// this to bring the selected row into view.
1591    ///
1592    /// Walks both the main split tree's leaves AND the inner leaves of
1593    /// all Grouped subtrees stored in `grouped_subtrees`, because the
1594    /// latter are not represented in `split_manager`'s tree.
1595    fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
1596        if !self.buffers.contains_key(&buffer_id) {
1597            return;
1598        }
1599
1600        // Collect the leaf ids whose active buffer is `buffer_id`.
1601        let mut target_leaves: Vec<LeafId> = Vec::new();
1602
1603        // Main tree: walk its leaves.
1604        for leaf_id in self.split_manager.root().leaf_split_ids() {
1605            if let Some(vs) = self.split_view_states.get(&leaf_id) {
1606                if vs.active_buffer == buffer_id {
1607                    target_leaves.push(leaf_id);
1608                }
1609            }
1610        }
1611
1612        // Grouped subtrees: walk each group's inner leaves.
1613        for (_group_leaf_id, node) in self.grouped_subtrees.iter() {
1614            if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
1615                for inner_leaf in layout.leaf_split_ids() {
1616                    if let Some(vs) = self.split_view_states.get(&inner_leaf) {
1617                        if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
1618                            target_leaves.push(inner_leaf);
1619                        }
1620                    }
1621                }
1622            }
1623        }
1624
1625        if target_leaves.is_empty() {
1626            return;
1627        }
1628
1629        let state = match self.buffers.get_mut(&buffer_id) {
1630            Some(s) => s,
1631            None => return,
1632        };
1633
1634        for leaf_id in target_leaves {
1635            let Some(view_state) = self.split_view_states.get_mut(&leaf_id) else {
1636                continue;
1637            };
1638            let viewport_height = view_state.viewport.height as usize;
1639            // Place `line` roughly a third of the viewport from the top so
1640            // the next few navigation steps don't immediately scroll again.
1641            let lines_above = viewport_height / 3;
1642            let target = line.saturating_sub(lines_above);
1643            view_state.viewport.scroll_to(&mut state.buffer, target);
1644            view_state.viewport.set_skip_ensure_visible();
1645        }
1646    }
1647
1648    fn handle_spawn_host_process(
1649        &mut self,
1650        command: String,
1651        args: Vec<String>,
1652        cwd: Option<String>,
1653        callback_id: JsCallbackId,
1654    ) {
1655        // Bypass the active authority on purpose: this is
1656        // reserved for plugin internals that must run host-side
1657        // work (e.g. `devcontainer up`) before the authority
1658        // they want is even built. Uses the same callback shape
1659        // as `SpawnProcess` so the plugin-facing API is
1660        // symmetric.
1661        //
1662        // Kill handle: we store a oneshot sender in
1663        // `host_process_handles` keyed by the callback id. A
1664        // `KillHostProcess` dispatch sends on it; the spawn
1665        // task's `tokio::select!` then start_kill()s the
1666        // child. This lets a plugin cancel a long-running
1667        // spawn (e.g. "Cancel Startup" on the Remote
1668        // Indicator popup during `devcontainer up`).
1669        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1670            use tokio::io::{AsyncReadExt, BufReader};
1671            use tokio::process::Command as TokioCommand;
1672
1673            let effective_cwd = cwd.or_else(|| {
1674                std::env::current_dir()
1675                    .map(|p| p.to_string_lossy().to_string())
1676                    .ok()
1677            });
1678            let sender = bridge.sender();
1679            let process_id = callback_id.as_u64();
1680
1681            let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
1682            self.host_process_handles.insert(process_id, kill_tx);
1683
1684            runtime.spawn(async move {
1685                use crate::services::process_hidden::HideWindow;
1686                let mut cmd = TokioCommand::new(&command);
1687                cmd.args(&args);
1688                cmd.stdout(std::process::Stdio::piped());
1689                cmd.stderr(std::process::Stdio::piped());
1690                cmd.hide_window();
1691                if let Some(ref dir) = effective_cwd {
1692                    cmd.current_dir(dir);
1693                }
1694                let mut child = match cmd.spawn() {
1695                    Ok(c) => c,
1696                    Err(e) => {
1697                        #[allow(clippy::let_underscore_must_use)]
1698                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
1699                            process_id,
1700                            stdout: String::new(),
1701                            stderr: e.to_string(),
1702                            exit_code: -1,
1703                        });
1704                        return;
1705                    }
1706                };
1707
1708                // Take the pipes out of the Child so the
1709                // reader tasks own them; then `child.wait()`
1710                // has exclusive mutable access for the
1711                // kill-or-exit select. Matches the
1712                // fresh-plugin-runtime process.rs pattern.
1713                let stdout_pipe = child.stdout.take();
1714                let stderr_pipe = child.stderr.take();
1715
1716                let stdout_fut = async {
1717                    let mut buf = String::new();
1718                    if let Some(s) = stdout_pipe {
1719                        #[allow(clippy::let_underscore_must_use)]
1720                        let _ = BufReader::new(s).read_to_string(&mut buf).await;
1721                    }
1722                    buf
1723                };
1724                let stderr_fut = async {
1725                    let mut buf = String::new();
1726                    if let Some(s) = stderr_pipe {
1727                        #[allow(clippy::let_underscore_must_use)]
1728                        let _ = BufReader::new(s).read_to_string(&mut buf).await;
1729                    }
1730                    buf
1731                };
1732                let wait_fut = async {
1733                    tokio::select! {
1734                        status = child.wait() => {
1735                            status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
1736                        }
1737                        _ = &mut kill_rx => {
1738                            // Best-effort SIGKILL + reap.
1739                            // Children of the killed
1740                            // process may leak (Q-C2).
1741                            #[allow(clippy::let_underscore_must_use)]
1742                            let _ = child.start_kill();
1743                            child
1744                                .wait()
1745                                .await
1746                                .map(|s| s.code().unwrap_or(-1))
1747                                .unwrap_or(-1)
1748                        }
1749                    }
1750                };
1751                let (stdout, stderr, exit_code) = tokio::join!(stdout_fut, stderr_fut, wait_fut);
1752
1753                #[allow(clippy::let_underscore_must_use)]
1754                let _ = sender.send(AsyncMessage::PluginProcessOutput {
1755                    process_id,
1756                    stdout,
1757                    stderr,
1758                    exit_code,
1759                });
1760            });
1761        } else {
1762            self.plugin_manager
1763                .reject_callback(callback_id, "Async runtime not available".to_string());
1764        }
1765    }
1766
1767    fn handle_spawn_background_process(
1768        &mut self,
1769        process_id: u64,
1770        command: String,
1771        args: Vec<String>,
1772        cwd: Option<String>,
1773        callback_id: JsCallbackId,
1774    ) {
1775        // Spawn background process with streaming output via tokio
1776        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1777            use tokio::io::{AsyncBufReadExt, BufReader};
1778            use tokio::process::Command as TokioCommand;
1779
1780            let effective_cwd = cwd.unwrap_or_else(|| {
1781                std::env::current_dir()
1782                    .map(|p| p.to_string_lossy().to_string())
1783                    .unwrap_or_else(|_| ".".to_string())
1784            });
1785
1786            let sender = bridge.sender();
1787            let sender_stdout = sender.clone();
1788            let sender_stderr = sender.clone();
1789            let callback_id_u64 = callback_id.as_u64();
1790
1791            // Receiver may be dropped if editor is shutting down
1792            #[allow(clippy::let_underscore_must_use)]
1793            let handle = runtime.spawn(async move {
1794                use crate::services::process_hidden::HideWindow;
1795                let mut child = match TokioCommand::new(&command)
1796                    .args(&args)
1797                    .current_dir(&effective_cwd)
1798                    .stdout(std::process::Stdio::piped())
1799                    .stderr(std::process::Stdio::piped())
1800                    .hide_window()
1801                    .spawn()
1802                {
1803                    Ok(child) => child,
1804                    Err(e) => {
1805                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1806                            fresh_core::api::PluginAsyncMessage::ProcessExit {
1807                                process_id,
1808                                callback_id: callback_id_u64,
1809                                exit_code: -1,
1810                            },
1811                        ));
1812                        tracing::error!("Failed to spawn background process: {}", e);
1813                        return;
1814                    }
1815                };
1816
1817                // Stream stdout
1818                let stdout = child.stdout.take();
1819                let stderr = child.stderr.take();
1820                let pid = process_id;
1821
1822                // Spawn stdout reader
1823                if let Some(stdout) = stdout {
1824                    let sender = sender_stdout;
1825                    tokio::spawn(async move {
1826                        let reader = BufReader::new(stdout);
1827                        let mut lines = reader.lines();
1828                        while let Ok(Some(line)) = lines.next_line().await {
1829                            let _ =
1830                                sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1831                                    fresh_core::api::PluginAsyncMessage::ProcessStdout {
1832                                        process_id: pid,
1833                                        data: line + "\n",
1834                                    },
1835                                ));
1836                        }
1837                    });
1838                }
1839
1840                // Spawn stderr reader
1841                if let Some(stderr) = stderr {
1842                    let sender = sender_stderr;
1843                    tokio::spawn(async move {
1844                        let reader = BufReader::new(stderr);
1845                        let mut lines = reader.lines();
1846                        while let Ok(Some(line)) = lines.next_line().await {
1847                            let _ =
1848                                sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1849                                    fresh_core::api::PluginAsyncMessage::ProcessStderr {
1850                                        process_id: pid,
1851                                        data: line + "\n",
1852                                    },
1853                                ));
1854                        }
1855                    });
1856                }
1857
1858                // Wait for process to complete
1859                let exit_code = match child.wait().await {
1860                    Ok(status) => status.code().unwrap_or(-1),
1861                    Err(_) => -1,
1862                };
1863
1864                let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1865                    fresh_core::api::PluginAsyncMessage::ProcessExit {
1866                        process_id,
1867                        callback_id: callback_id_u64,
1868                        exit_code,
1869                    },
1870                ));
1871            });
1872
1873            // Store abort handle for potential kill
1874            self.background_process_handles
1875                .insert(process_id, handle.abort_handle());
1876        } else {
1877            // No runtime - reject immediately
1878            self.plugin_manager
1879                .reject_callback(callback_id, "Async runtime not available".to_string());
1880        }
1881    }
1882
1883    fn handle_create_virtual_buffer_with_content(
1884        &mut self,
1885        name: String,
1886        mode: String,
1887        read_only: bool,
1888        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1889        show_line_numbers: bool,
1890        show_cursors: bool,
1891        editing_disabled: bool,
1892        hidden_from_tabs: bool,
1893        request_id: Option<u64>,
1894    ) {
1895        let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1896        tracing::info!(
1897            "Created virtual buffer '{}' with mode '{}' (id={:?})",
1898            name,
1899            mode,
1900            buffer_id
1901        );
1902
1903        // Apply view options to the buffer
1904        // TODO: show_line_numbers is duplicated between EditorState.margins and
1905        // BufferViewState. The renderer reads BufferViewState and overwrites
1906        // margins each frame via configure_for_line_numbers(), making the margin
1907        // setting here effectively write-only. Consider removing the margin call
1908        // and only setting BufferViewState.show_line_numbers.
1909        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1910            state.margins.configure_for_line_numbers(show_line_numbers);
1911            state.show_cursors = show_cursors;
1912            state.editing_disabled = editing_disabled;
1913            tracing::debug!(
1914                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
1915                        buffer_id,
1916                        show_line_numbers,
1917                        show_cursors,
1918                        editing_disabled
1919                    );
1920        }
1921        let active_split = self.split_manager.active_split();
1922        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1923            view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
1924        }
1925
1926        // Apply hidden_from_tabs to buffer metadata
1927        if hidden_from_tabs {
1928            if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
1929                meta.hidden_from_tabs = true;
1930            }
1931        }
1932
1933        // Now set the content
1934        match self.set_virtual_buffer_content(buffer_id, entries) {
1935            Ok(()) => {
1936                tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
1937                // Switch to the new buffer to display it
1938                self.set_active_buffer(buffer_id);
1939                tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
1940
1941                // Send response if request_id is present
1942                if let Some(req_id) = request_id {
1943                    tracing::info!(
1944                                "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
1945                                req_id,
1946                                buffer_id
1947                            );
1948                    // createVirtualBuffer returns VirtualBufferResult: { bufferId, splitId }
1949                    let result = fresh_core::api::VirtualBufferResult {
1950                        buffer_id: buffer_id.0 as u64,
1951                        split_id: None,
1952                    };
1953                    self.plugin_manager.resolve_callback(
1954                        fresh_core::api::JsCallbackId::from(req_id),
1955                        serde_json::to_string(&result).unwrap_or_default(),
1956                    );
1957                    tracing::info!(
1958                        "CreateVirtualBufferWithContent: resolve_callback sent for request_id={}",
1959                        req_id
1960                    );
1961                }
1962            }
1963            Err(e) => {
1964                tracing::error!("Failed to set virtual buffer content: {}", e);
1965            }
1966        }
1967    }
1968
1969    fn handle_create_virtual_buffer_in_split(
1970        &mut self,
1971        name: String,
1972        mode: String,
1973        read_only: bool,
1974        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1975        ratio: f32,
1976        direction: Option<String>,
1977        panel_id: Option<String>,
1978        show_line_numbers: bool,
1979        show_cursors: bool,
1980        editing_disabled: bool,
1981        line_wrap: Option<bool>,
1982        before: bool,
1983        request_id: Option<u64>,
1984    ) {
1985        // Check if this panel already exists (for idempotent operations)
1986        if let Some(pid) = &panel_id {
1987            if let Some(&existing_buffer_id) = self.panel_ids.get(pid) {
1988                // Verify the buffer actually exists (defensive check for stale entries)
1989                if self.buffers.contains_key(&existing_buffer_id) {
1990                    // Panel exists, just update its content
1991                    if let Err(e) = self.set_virtual_buffer_content(existing_buffer_id, entries) {
1992                        tracing::error!("Failed to update panel content: {}", e);
1993                    } else {
1994                        tracing::info!("Updated existing panel '{}' content", pid);
1995                    }
1996
1997                    // Find and focus the split that contains this buffer
1998                    let splits = self.split_manager.splits_for_buffer(existing_buffer_id);
1999                    if let Some(&split_id) = splits.first() {
2000                        self.split_manager.set_active_split(split_id);
2001                        // Route through set_pane_buffer so tree + SVS
2002                        // stay consistent (issue #1620 invariant).
2003                        self.set_pane_buffer(split_id, existing_buffer_id);
2004                        tracing::debug!("Focused split {:?} containing panel buffer", split_id);
2005                    }
2006
2007                    // Send response with existing buffer ID and split ID via callback resolution
2008                    if let Some(req_id) = request_id {
2009                        let result = fresh_core::api::VirtualBufferResult {
2010                            buffer_id: existing_buffer_id.0 as u64,
2011                            split_id: splits.first().map(|s| s.0 .0 as u64),
2012                        };
2013                        self.plugin_manager.resolve_callback(
2014                            fresh_core::api::JsCallbackId::from(req_id),
2015                            serde_json::to_string(&result).unwrap_or_default(),
2016                        );
2017                    }
2018                    return;
2019                } else {
2020                    // Buffer no longer exists, remove stale panel_id entry
2021                    tracing::warn!(
2022                        "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
2023                        pid,
2024                        existing_buffer_id
2025                    );
2026                    self.panel_ids.remove(pid);
2027                    // Fall through to create a new buffer
2028                }
2029            }
2030        }
2031
2032        // Capture the source split before creating the buffer —
2033        // `create_virtual_buffer` unconditionally adds the new buffer
2034        // as a tab to the currently active split, which is the wrong
2035        // thing for a panel that lives in its own dedicated split
2036        // (it would show up as a tab in BOTH splits — see bug #3).
2037        let source_split_before_create = self.split_manager.active_split();
2038
2039        // Create the virtual buffer first
2040        let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
2041        tracing::info!(
2042            "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
2043            name,
2044            mode,
2045            buffer_id
2046        );
2047
2048        // Apply view options to the buffer
2049        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2050            state.margins.configure_for_line_numbers(show_line_numbers);
2051            state.show_cursors = show_cursors;
2052            state.editing_disabled = editing_disabled;
2053            tracing::debug!(
2054                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
2055                        buffer_id,
2056                        show_line_numbers,
2057                        show_cursors,
2058                        editing_disabled
2059                    );
2060        }
2061
2062        // Store the panel ID mapping if provided
2063        if let Some(pid) = panel_id {
2064            self.panel_ids.insert(pid, buffer_id);
2065        }
2066
2067        // Set the content
2068        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2069            tracing::error!("Failed to set virtual buffer content: {}", e);
2070            return;
2071        }
2072
2073        // Determine split direction
2074        let split_dir = match direction.as_deref() {
2075            Some("vertical") => crate::model::event::SplitDirection::Vertical,
2076            _ => crate::model::event::SplitDirection::Horizontal,
2077        };
2078
2079        // Create a split with the new buffer
2080        let created_split_id = match self
2081            .split_manager
2082            .split_active_positioned(split_dir, buffer_id, ratio, before)
2083        {
2084            Ok(new_split_id) => {
2085                // The buffer now lives in its own split, so drop its
2086                // tab from the source split (see bug #3).  Only do
2087                // this when the new split actually differs from the
2088                // source split — otherwise we'd leave no split
2089                // displaying the buffer.
2090                if new_split_id != source_split_before_create {
2091                    if let Some(source_view_state) =
2092                        self.split_view_states.get_mut(&source_split_before_create)
2093                    {
2094                        source_view_state.remove_buffer(buffer_id);
2095                    }
2096                }
2097                // Create independent view state for the new split with the buffer in tabs
2098                let mut view_state = SplitViewState::with_buffer(
2099                    self.terminal_width,
2100                    self.terminal_height,
2101                    buffer_id,
2102                );
2103                view_state.apply_config_defaults(
2104                    self.config.editor.line_numbers,
2105                    self.config.editor.highlight_current_line,
2106                    line_wrap.unwrap_or_else(|| self.resolve_line_wrap_for_buffer(buffer_id)),
2107                    self.config.editor.wrap_indent,
2108                    self.resolve_wrap_column_for_buffer(buffer_id),
2109                    self.config.editor.rulers.clone(),
2110                );
2111                // Override with plugin-requested show_line_numbers
2112                view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2113                self.split_view_states.insert(new_split_id, view_state);
2114
2115                // Focus the new split (the diagnostics panel)
2116                self.split_manager.set_active_split(new_split_id);
2117                // NOTE: split tree was updated by split_active, active_buffer derives from it
2118
2119                tracing::info!(
2120                    "Created {:?} split with virtual buffer {:?}",
2121                    split_dir,
2122                    buffer_id
2123                );
2124                Some(new_split_id)
2125            }
2126            Err(e) => {
2127                tracing::error!("Failed to create split: {}", e);
2128                // Fall back to just switching to the buffer
2129                self.set_active_buffer(buffer_id);
2130                None
2131            }
2132        };
2133
2134        // Send response with buffer ID and split ID via callback resolution
2135        // NOTE: Using VirtualBufferResult type for type-safe JSON serialization
2136        if let Some(req_id) = request_id {
2137            tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
2138            let result = fresh_core::api::VirtualBufferResult {
2139                buffer_id: buffer_id.0 as u64,
2140                split_id: created_split_id.map(|s| s.0 .0 as u64),
2141            };
2142            self.plugin_manager.resolve_callback(
2143                fresh_core::api::JsCallbackId::from(req_id),
2144                serde_json::to_string(&result).unwrap_or_default(),
2145            );
2146        }
2147    }
2148
2149    fn handle_create_virtual_buffer_in_existing_split(
2150        &mut self,
2151        name: String,
2152        mode: String,
2153        read_only: bool,
2154        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2155        split_id: SplitId,
2156        show_line_numbers: bool,
2157        show_cursors: bool,
2158        editing_disabled: bool,
2159        line_wrap: Option<bool>,
2160        request_id: Option<u64>,
2161    ) {
2162        // Create the virtual buffer
2163        let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
2164        tracing::info!(
2165            "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
2166            name,
2167            mode,
2168            split_id,
2169            buffer_id
2170        );
2171
2172        // Apply view options to the buffer
2173        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2174            state.margins.configure_for_line_numbers(show_line_numbers);
2175            state.show_cursors = show_cursors;
2176            state.editing_disabled = editing_disabled;
2177        }
2178
2179        // Set the content
2180        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2181            tracing::error!("Failed to set virtual buffer content: {}", e);
2182            return;
2183        }
2184
2185        // Show the buffer in the target split. set_pane_buffer
2186        // covers the tree + SVS updates the old code did by hand.
2187        let leaf_id = LeafId(split_id);
2188        self.split_manager.set_active_split(leaf_id);
2189        self.set_pane_buffer(leaf_id, buffer_id);
2190
2191        // Fall-through to the cursor/open_buffers housekeeping
2192        // that used to follow the manual switch_buffer. We keep
2193        // the `if let Some(view_state)` block below — set_pane_buffer
2194        // already called switch_buffer, but the downstream code
2195        // also nudges open_buffers and focus_history.
2196        if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
2197            view_state.switch_buffer(buffer_id);
2198            view_state.add_buffer(buffer_id);
2199            view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2200
2201            // Apply line_wrap setting if provided
2202            if let Some(wrap) = line_wrap {
2203                view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
2204            }
2205        }
2206
2207        tracing::info!(
2208            "Displayed virtual buffer {:?} in split {:?}",
2209            buffer_id,
2210            split_id
2211        );
2212
2213        // Send response with buffer ID and split ID via callback resolution
2214        if let Some(req_id) = request_id {
2215            let result = fresh_core::api::VirtualBufferResult {
2216                buffer_id: buffer_id.0 as u64,
2217                split_id: Some(split_id.0 as u64),
2218            };
2219            self.plugin_manager.resolve_callback(
2220                fresh_core::api::JsCallbackId::from(req_id),
2221                serde_json::to_string(&result).unwrap_or_default(),
2222            );
2223        }
2224    }
2225
2226    fn handle_show_action_popup(
2227        &mut self,
2228        popup_id: String,
2229        title: String,
2230        message: String,
2231        actions: Vec<fresh_core::api::ActionPopupAction>,
2232    ) {
2233        tracing::info!(
2234            "Action popup requested: id={}, title={}, actions={}",
2235            popup_id,
2236            title,
2237            actions.len()
2238        );
2239
2240        // Build popup list items from actions
2241        let items: Vec<crate::model::event::PopupListItemData> = actions
2242            .iter()
2243            .map(|action| crate::model::event::PopupListItemData {
2244                text: action.label.clone(),
2245                detail: None,
2246                icon: None,
2247                data: Some(action.id.clone()),
2248            })
2249            .collect();
2250
2251        // The popup_id lives on the popup itself via its
2252        // `PopupResolver::PluginAction` — no side-channel stack.
2253        // Drop the incoming `actions` vec; its ids are already
2254        // encoded as each list item's `data` field below.
2255        drop(actions);
2256
2257        // Create popup with message + action list
2258        let popup_data = crate::model::event::PopupData {
2259            kind: crate::model::event::PopupKindHint::List,
2260            title: Some(title),
2261            description: Some(message),
2262            transient: false,
2263            content: crate::model::event::PopupContentData::List { items, selected: 0 },
2264            position: crate::model::event::PopupPositionData::BottomRight,
2265            width: 60,
2266            max_height: 15,
2267            bordered: true,
2268        };
2269
2270        // Action popups are buffer-independent notifications; route
2271        // them to the editor-level popup stack so they remain visible
2272        // (and dismissible) regardless of which buffer is focused —
2273        // including virtual buffers like the Dashboard that own the
2274        // whole split.
2275        //
2276        // The resolver carries the popup_id so confirm/cancel fires
2277        // `action_popup_result` for exactly THIS popup, even when
2278        // multiple plugin popups are stacked concurrently.
2279        let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
2280        popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
2281            popup_id: popup_id.clone(),
2282        };
2283        self.global_popups.show(popup_obj);
2284        tracing::info!(
2285            "Action popup shown: id={}, stack_depth={}",
2286            popup_id,
2287            self.global_popups.all().len()
2288        );
2289    }
2290
2291    fn handle_create_terminal(
2292        &mut self,
2293        cwd: Option<String>,
2294        direction: Option<String>,
2295        ratio: Option<f32>,
2296        focus: Option<bool>,
2297        persistent: bool,
2298        request_id: u64,
2299    ) {
2300        let (cols, rows) = self.get_terminal_dimensions();
2301
2302        // Set up async bridge for terminal manager if not already done
2303        if let Some(ref bridge) = self.async_bridge {
2304            self.terminal_manager.set_async_bridge(bridge.clone());
2305        }
2306
2307        // Determine working directory
2308        let working_dir = cwd
2309            .map(std::path::PathBuf::from)
2310            .unwrap_or_else(|| self.working_dir.clone());
2311
2312        // Prepare persistent storage paths
2313        let terminal_root = self.dir_context.terminal_dir_for(&working_dir);
2314        if let Err(e) = self.authority.filesystem.create_dir_all(&terminal_root) {
2315            tracing::warn!("Failed to create terminal directory: {}", e);
2316        }
2317        let predicted_terminal_id = self.terminal_manager.next_terminal_id();
2318        // Ephemeral terminals get a per-spawn suffix on their backing
2319        // files so there is no possibility of picking up the scrollback
2320        // that a previous run (with the same numeric terminal ID) wrote
2321        // to `fresh-terminal-N.{txt,log}`. Persistent terminals keep
2322        // the stable `fresh-terminal-N.*` name so workspace restore
2323        // can still find them.
2324        let name_stem = if persistent {
2325            format!("fresh-terminal-{}", predicted_terminal_id.0)
2326        } else {
2327            let nanos = std::time::SystemTime::now()
2328                .duration_since(std::time::UNIX_EPOCH)
2329                .map(|d| d.as_nanos())
2330                .unwrap_or(0);
2331            format!("fresh-terminal-eph-{}-{}", predicted_terminal_id.0, nanos)
2332        };
2333        let log_path = terminal_root.join(format!("{}.log", name_stem));
2334        let backing_path = terminal_root.join(format!("{}.txt", name_stem));
2335        self.terminal_backing_files
2336            .insert(predicted_terminal_id, backing_path);
2337        let backing_path_for_spawn = self
2338            .terminal_backing_files
2339            .get(&predicted_terminal_id)
2340            .cloned();
2341
2342        match self.terminal_manager.spawn(
2343            cols,
2344            rows,
2345            Some(working_dir),
2346            Some(log_path.clone()),
2347            backing_path_for_spawn,
2348            self.resolved_terminal_wrapper(),
2349        ) {
2350            Ok(terminal_id) => {
2351                // Track log file path
2352                self.terminal_log_files
2353                    .insert(terminal_id, log_path.clone());
2354                // Fix up backing path if the predicted ID didn't match
2355                // the one the terminal manager handed out. Persistent
2356                // terminals re-derive the stable `fresh-terminal-N.txt`
2357                // name so the workspace restore path can find them;
2358                // ephemeral terminals just keep the already-spawned
2359                // file (it has a nanos-unique name either way) and
2360                // rebind the HashMap key to the real ID.
2361                if terminal_id != predicted_terminal_id {
2362                    let existing = self.terminal_backing_files.remove(&predicted_terminal_id);
2363                    let fixed_backing = if persistent {
2364                        terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
2365                    } else {
2366                        existing.unwrap_or_else(|| terminal_root.join(format!("{}.txt", name_stem)))
2367                    };
2368                    self.terminal_backing_files
2369                        .insert(terminal_id, fixed_backing);
2370                }
2371                if !persistent {
2372                    self.ephemeral_terminals.insert(terminal_id);
2373                }
2374
2375                // Pick buffer-attachment strategy based on whether the
2376                // plugin asked for its own split:
2377                //
2378                // - direction = Some: use `_detached` so the buffer
2379                //   isn't also added as a tab to the user's active
2380                //   split. The new split below owns it exclusively,
2381                //   so when the user closes that split the terminal
2382                //   disappears entirely instead of leaving a ghost
2383                //   tab behind in the main split.
2384                // - direction = None: use `_attached` — the plugin
2385                //   is intentionally placing the terminal as a new
2386                //   tab in the active split, which is the whole
2387                //   point of the no-split branch.
2388                let active_split = self.split_manager.active_split();
2389                let buffer_id = if direction.is_some() {
2390                    self.create_terminal_buffer_detached(terminal_id)
2391                } else {
2392                    self.create_terminal_buffer_attached(terminal_id, active_split)
2393                };
2394
2395                let created_split_id = if let Some(dir_str) = direction.as_deref() {
2396                    let split_dir = match dir_str {
2397                        "horizontal" => crate::model::event::SplitDirection::Horizontal,
2398                        _ => crate::model::event::SplitDirection::Vertical,
2399                    };
2400
2401                    let split_ratio = ratio.unwrap_or(0.5);
2402                    match self
2403                        .split_manager
2404                        .split_active(split_dir, buffer_id, split_ratio)
2405                    {
2406                        Ok(new_split_id) => {
2407                            let mut view_state = SplitViewState::with_buffer(
2408                                self.terminal_width,
2409                                self.terminal_height,
2410                                buffer_id,
2411                            );
2412                            view_state.apply_config_defaults(
2413                                self.config.editor.line_numbers,
2414                                self.config.editor.highlight_current_line,
2415                                false,
2416                                false,
2417                                None,
2418                                self.config.editor.rulers.clone(),
2419                            );
2420                            // Terminal output is ANSI-sequenced and
2421                            // assumes a fixed column count; wrapping
2422                            // would mangle cursor positioning.
2423                            view_state.viewport.line_wrap_enabled = false;
2424                            self.split_view_states.insert(new_split_id, view_state);
2425
2426                            if focus.unwrap_or(true) {
2427                                self.split_manager.set_active_split(new_split_id);
2428                            }
2429
2430                            tracing::info!(
2431                                "Created {:?} split for terminal {:?} with buffer {:?}",
2432                                split_dir,
2433                                terminal_id,
2434                                buffer_id
2435                            );
2436                            Some(new_split_id)
2437                        }
2438                        Err(e) => {
2439                            tracing::error!(
2440                                "Failed to create split for terminal: {}; \
2441                                         falling back to active split",
2442                                e
2443                            );
2444                            // The buffer was created detached. Split
2445                            // creation failed, so attach it to the
2446                            // active split as a graceful fallback
2447                            // rather than leaving an orphan buffer.
2448                            if let Some(view_state) = self.split_view_states.get_mut(&active_split)
2449                            {
2450                                view_state.add_buffer(buffer_id);
2451                                view_state.viewport.line_wrap_enabled = false;
2452                            }
2453                            self.set_active_buffer(buffer_id);
2454                            None
2455                        }
2456                    }
2457                } else {
2458                    // No split — just switch to the terminal buffer in the active split
2459                    self.set_active_buffer(buffer_id);
2460                    None
2461                };
2462
2463                // Resize terminal to match actual split content area
2464                self.resize_visible_terminals();
2465
2466                // Resolve the callback with TerminalResult
2467                let result = fresh_core::api::TerminalResult {
2468                    buffer_id: buffer_id.0 as u64,
2469                    terminal_id: terminal_id.0 as u64,
2470                    split_id: created_split_id.map(|s| s.0 .0 as u64),
2471                };
2472                self.plugin_manager.resolve_callback(
2473                    fresh_core::api::JsCallbackId::from(request_id),
2474                    serde_json::to_string(&result).unwrap_or_default(),
2475                );
2476
2477                tracing::info!(
2478                    "Plugin created terminal {:?} with buffer {:?}",
2479                    terminal_id,
2480                    buffer_id
2481                );
2482            }
2483            Err(e) => {
2484                tracing::error!("Failed to create terminal for plugin: {}", e);
2485                self.plugin_manager.reject_callback(
2486                    fresh_core::api::JsCallbackId::from(request_id),
2487                    format!("Failed to create terminal: {}", e),
2488                );
2489            }
2490        }
2491    }
2492    // ==================== Extracted handlers for previously inline match arms ====================
2493
2494    fn handle_get_split_by_label(&mut self, label: String, request_id: u64) {
2495        let split_id = self.split_manager.find_split_by_label(&label);
2496        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2497        let json =
2498            serde_json::to_string(&split_id.map(|s| s.0 .0)).unwrap_or_else(|_| "null".to_string());
2499        self.plugin_manager.resolve_callback(callback_id, json);
2500    }
2501
2502    fn handle_set_buffer_show_cursors(&mut self, buffer_id: BufferId, show: bool) {
2503        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2504            state.show_cursors = show;
2505        } else {
2506            tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
2507        }
2508    }
2509
2510    fn handle_override_theme_colors(
2511        &mut self,
2512        overrides: std::collections::HashMap<String, [u8; 3]>,
2513    ) {
2514        let pairs = overrides
2515            .into_iter()
2516            .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
2517        let applied = self.theme.override_colors(pairs);
2518        if applied > 0 {
2519            // Diagnostics / semantic overlays bake RGB at creation time — rebuild
2520            // them so the override is visible everywhere on the next frame.
2521            self.reapply_all_overlays();
2522        }
2523    }
2524
2525    fn handle_await_next_key(&mut self, callback_id: fresh_core::api::JsCallbackId) {
2526        // If keys arrived during a key-capture window while no callback was
2527        // pending, drain the front-most buffered key and resolve immediately.
2528        // Otherwise enqueue the callback for the next live keypress.
2529        if let Some(payload) = self.pending_key_capture_buffer.pop_front() {
2530            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
2531            self.plugin_manager.resolve_callback(callback_id, json);
2532        } else {
2533            self.pending_next_key_callbacks.push_back(callback_id);
2534        }
2535    }
2536
2537    fn handle_spawn_process(
2538        &mut self,
2539        command: String,
2540        args: Vec<String>,
2541        cwd: Option<String>,
2542        callback_id: fresh_core::api::JsCallbackId,
2543    ) {
2544        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2545            let effective_cwd = cwd.or_else(|| {
2546                std::env::current_dir()
2547                    .map(|p| p.to_string_lossy().to_string())
2548                    .ok()
2549            });
2550            let sender = bridge.sender();
2551            let spawner = self.authority.process_spawner.clone();
2552            runtime.spawn(async move {
2553                #[allow(clippy::let_underscore_must_use)]
2554                match spawner.spawn(command, args, effective_cwd).await {
2555                    Ok(result) => {
2556                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
2557                            process_id: callback_id.as_u64(),
2558                            stdout: result.stdout,
2559                            stderr: result.stderr,
2560                            exit_code: result.exit_code,
2561                        });
2562                    }
2563                    Err(e) => {
2564                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
2565                            process_id: callback_id.as_u64(),
2566                            stdout: String::new(),
2567                            stderr: e.to_string(),
2568                            exit_code: -1,
2569                        });
2570                    }
2571                }
2572            });
2573        } else {
2574            self.plugin_manager
2575                .reject_callback(callback_id, "Async runtime not available".to_string());
2576        }
2577    }
2578
2579    fn handle_kill_host_process(&mut self, process_id: u64) {
2580        // Removing from the map gives us the oneshot sender. Firing it signals
2581        // the spawn task to start_kill() the child and reap. Unknown IDs are
2582        // intentionally silent — the process may have already exited.
2583        if let Some(tx) = self.host_process_handles.remove(&process_id) {
2584            #[allow(clippy::let_underscore_must_use)]
2585            let _ = tx.send(());
2586            tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
2587        } else {
2588            tracing::debug!(
2589                "KillHostProcess: unknown process_id={} (already exited?)",
2590                process_id
2591            );
2592        }
2593    }
2594
2595    fn handle_set_authority(&mut self, payload: serde_json::Value) {
2596        // Payload is opaque at the fresh-core layer; the concrete schema lives
2597        // in services::authority::AuthorityPayload so core stays ignorant of backend kinds.
2598        match serde_json::from_value::<crate::services::authority::AuthorityPayload>(payload) {
2599            Ok(parsed) => {
2600                match crate::services::authority::Authority::from_plugin_payload(parsed) {
2601                    Ok(auth) => {
2602                        tracing::info!("Plugin installed new authority");
2603                        self.install_authority(auth);
2604                    }
2605                    Err(e) => {
2606                        tracing::warn!("setAuthority: invalid payload: {}", e);
2607                        self.set_status_message(format!("setAuthority rejected: {}", e));
2608                    }
2609                }
2610            }
2611            Err(e) => {
2612                tracing::warn!("setAuthority: failed to parse payload: {}", e);
2613                self.set_status_message(format!("setAuthority rejected: {}", e));
2614            }
2615        }
2616    }
2617
2618    fn handle_set_remote_indicator_state(&mut self, state: serde_json::Value) {
2619        // Opaque JSON at the fresh-core boundary; the concrete schema
2620        // (RemoteIndicatorOverride) lives in the view crate.
2621        match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(state)
2622        {
2623            Ok(over) => {
2624                self.remote_indicator_override = Some(over);
2625            }
2626            Err(e) => {
2627                tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
2628                self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
2629            }
2630        }
2631    }
2632
2633    fn handle_spawn_process_wait(
2634        &mut self,
2635        process_id: u64,
2636        callback_id: fresh_core::api::JsCallbackId,
2637    ) {
2638        tracing::warn!(
2639            "SpawnProcessWait not fully implemented - process_id={}",
2640            process_id
2641        );
2642        self.plugin_manager.reject_callback(
2643            callback_id,
2644            format!(
2645                "SpawnProcessWait not yet fully implemented for process_id={}",
2646                process_id
2647            ),
2648        );
2649    }
2650
2651    fn handle_delay(&mut self, callback_id: fresh_core::api::JsCallbackId, duration_ms: u64) {
2652        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2653            let sender = bridge.sender();
2654            let callback_id_u64 = callback_id.as_u64();
2655            runtime.spawn(async move {
2656                tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
2657                #[allow(clippy::let_underscore_must_use)]
2658                let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2659                    fresh_core::api::PluginAsyncMessage::DelayComplete {
2660                        callback_id: callback_id_u64,
2661                    },
2662                ));
2663            });
2664        } else {
2665            std::thread::sleep(std::time::Duration::from_millis(duration_ms));
2666            self.plugin_manager
2667                .resolve_callback(callback_id, "null".to_string());
2668        }
2669    }
2670
2671    fn handle_kill_background_process(&mut self, process_id: u64) {
2672        if let Some(handle) = self.background_process_handles.remove(&process_id) {
2673            handle.abort();
2674            tracing::debug!("Killed background process {}", process_id);
2675        }
2676    }
2677
2678    fn handle_create_virtual_buffer(&mut self, name: String, mode: String, read_only: bool) {
2679        let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
2680        tracing::info!(
2681            "Created virtual buffer '{}' with mode '{}' (id={:?})",
2682            name,
2683            mode,
2684            buffer_id
2685        );
2686        // TODO: Return buffer_id to plugin via callback or hook
2687    }
2688
2689    fn handle_set_virtual_buffer_content(
2690        &mut self,
2691        buffer_id: BufferId,
2692        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2693    ) {
2694        match self.set_virtual_buffer_content(buffer_id, entries) {
2695            Ok(()) => {
2696                tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
2697            }
2698            Err(e) => {
2699                tracing::error!("Failed to set virtual buffer content: {}", e);
2700            }
2701        }
2702    }
2703
2704    fn handle_get_text_properties_at_cursor(&self, buffer_id: BufferId) {
2705        if let Some(state) = self.buffers.get(&buffer_id) {
2706            let cursor_pos = self
2707                .split_view_states
2708                .values()
2709                .find_map(|vs| vs.buffer_state(buffer_id))
2710                .map(|bs| bs.cursors.primary().position)
2711                .unwrap_or(0);
2712            let properties = state.text_properties.get_at(cursor_pos);
2713            tracing::debug!(
2714                "Text properties at cursor in {:?}: {} properties found",
2715                buffer_id,
2716                properties.len()
2717            );
2718            // TODO: Fire hook with properties data for plugins to consume
2719        }
2720    }
2721
2722    fn handle_set_context(&mut self, name: String, active: bool) {
2723        if active {
2724            self.active_custom_contexts.insert(name.clone());
2725            tracing::debug!("Set custom context: {}", name);
2726        } else {
2727            self.active_custom_contexts.remove(&name);
2728            tracing::debug!("Unset custom context: {}", name);
2729        }
2730    }
2731
2732    fn handle_disable_lsp_for_language(&mut self, language: String) {
2733        tracing::info!("Disabling LSP for language: {}", language);
2734        if let Some(ref mut lsp) = self.lsp {
2735            lsp.shutdown_server(&language);
2736            tracing::info!("Stopped LSP server for {}", language);
2737        }
2738        if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
2739            for c in lsp_configs.as_mut_slice() {
2740                c.enabled = false;
2741                c.auto_start = false;
2742            }
2743            tracing::info!("Disabled LSP config for {}", language);
2744        }
2745        if let Err(e) = self.save_config() {
2746            tracing::error!("Failed to save config: {}", e);
2747            self.status_message = Some(format!(
2748                "LSP disabled for {} (config save failed)",
2749                language
2750            ));
2751        } else {
2752            self.status_message = Some(format!("LSP disabled for {}", language));
2753        }
2754        self.warning_domains.lsp.clear();
2755    }
2756
2757    fn handle_restart_lsp_for_language(&mut self, language: String) {
2758        tracing::info!("Plugin restarting LSP for language: {}", language);
2759        let file_path = self
2760            .buffer_metadata
2761            .get(&self.active_buffer())
2762            .and_then(|meta| meta.file_path().cloned());
2763        let success = if let Some(ref mut lsp) = self.lsp {
2764            let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
2765            self.status_message = Some(msg);
2766            ok
2767        } else {
2768            self.status_message = Some("No LSP manager available".to_string());
2769            false
2770        };
2771        if success {
2772            self.reopen_buffers_for_language(&language);
2773        }
2774    }
2775
2776    fn handle_set_lsp_root_uri(&mut self, language: String, uri: String) {
2777        tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
2778        match uri.parse::<lsp_types::Uri>() {
2779            Ok(parsed_uri) => {
2780                if let Some(ref mut lsp) = self.lsp {
2781                    let restarted = lsp.set_language_root_uri(&language, parsed_uri);
2782                    if restarted {
2783                        self.status_message = Some(format!(
2784                            "LSP root updated for {} (restarting server)",
2785                            language
2786                        ));
2787                    } else {
2788                        self.status_message = Some(format!("LSP root set for {}", language));
2789                    }
2790                }
2791            }
2792            Err(e) => {
2793                tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
2794                self.status_message = Some(format!("Invalid LSP root URI: {}", e));
2795            }
2796        }
2797    }
2798
2799    fn handle_create_scroll_sync_group(
2800        &mut self,
2801        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
2802        left_split: SplitId,
2803        right_split: SplitId,
2804    ) {
2805        let success =
2806            self.scroll_sync_manager
2807                .create_group_with_id(group_id, left_split, right_split);
2808        if success {
2809            tracing::debug!(
2810                "Created scroll sync group {} for splits {:?} and {:?}",
2811                group_id,
2812                left_split,
2813                right_split
2814            );
2815        } else {
2816            tracing::warn!(
2817                "Failed to create scroll sync group {} (ID already exists)",
2818                group_id
2819            );
2820        }
2821    }
2822
2823    fn handle_set_scroll_sync_anchors(
2824        &mut self,
2825        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
2826        anchors: Vec<(usize, usize)>,
2827    ) {
2828        use crate::view::scroll_sync::SyncAnchor;
2829        let anchor_count = anchors.len();
2830        let sync_anchors: Vec<SyncAnchor> = anchors
2831            .into_iter()
2832            .map(|(left_line, right_line)| SyncAnchor {
2833                left_line,
2834                right_line,
2835            })
2836            .collect();
2837        self.scroll_sync_manager.set_anchors(group_id, sync_anchors);
2838        tracing::debug!(
2839            "Set {} anchors for scroll sync group {}",
2840            anchor_count,
2841            group_id
2842        );
2843    }
2844
2845    fn handle_remove_scroll_sync_group(
2846        &mut self,
2847        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
2848    ) {
2849        if self.scroll_sync_manager.remove_group(group_id) {
2850            tracing::debug!("Removed scroll sync group {}", group_id);
2851        } else {
2852            tracing::warn!("Scroll sync group {} not found", group_id);
2853        }
2854    }
2855
2856    fn handle_create_buffer_group(
2857        &mut self,
2858        name: String,
2859        mode: String,
2860        layout_json: String,
2861        request_id: Option<u64>,
2862    ) {
2863        match self.create_buffer_group(name, mode, layout_json) {
2864            Ok(result) => {
2865                if let Some(req_id) = request_id {
2866                    let json = serde_json::to_string(&result).unwrap_or_default();
2867                    self.plugin_manager
2868                        .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
2869                }
2870            }
2871            Err(e) => {
2872                tracing::error!("Failed to create buffer group: {}", e);
2873            }
2874        }
2875    }
2876
2877    fn handle_send_terminal_input(
2878        &mut self,
2879        terminal_id: crate::services::terminal::TerminalId,
2880        data: String,
2881    ) {
2882        if let Some(handle) = self.terminal_manager.get(terminal_id) {
2883            handle.write(data.as_bytes());
2884            tracing::trace!(
2885                "Plugin sent {} bytes to terminal {:?}",
2886                data.len(),
2887                terminal_id
2888            );
2889        } else {
2890            tracing::warn!(
2891                "Plugin tried to send input to non-existent terminal {:?}",
2892                terminal_id
2893            );
2894        }
2895    }
2896
2897    fn handle_close_terminal(&mut self, terminal_id: crate::services::terminal::TerminalId) {
2898        let buffer_to_close = self
2899            .terminal_buffers
2900            .iter()
2901            .find(|(_, &tid)| tid == terminal_id)
2902            .map(|(&bid, _)| bid);
2903        if let Some(buffer_id) = buffer_to_close {
2904            if let Err(e) = self.close_buffer(buffer_id) {
2905                tracing::warn!("Failed to close terminal buffer: {}", e);
2906            }
2907            tracing::info!("Plugin closed terminal {:?}", terminal_id);
2908        } else {
2909            self.terminal_manager.close(terminal_id);
2910            tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
2911        }
2912    }
2913}
2914
2915#[cfg(test)]
2916mod tests {
2917    //! Focused tests for the SpawnHostProcess kill mechanism.
2918    //!
2919    //! These don't exercise the full `handle_plugin_command` dispatcher
2920    //! (which would require scaffolding an Editor with a real tokio
2921    //! runtime and async_bridge); they replicate the inner
2922    //! `tokio::select!` pattern directly on a real subprocess. A
2923    //! regression in the select arms or in the kill-then-wait
2924    //! sequencing would reproduce here.
2925    //!
2926    //! The dispatcher-level integration coverage comes from the e2e
2927    //! attach-cancel test in `tests/e2e/` — this unit test is the
2928    //! lower-level pin.
2929    use tokio::io::{AsyncReadExt, BufReader};
2930    use tokio::process::Command as TokioCommand;
2931    use tokio::time::{timeout, Duration};
2932
2933    /// A long-sleep child that runs `tokio::select! { wait | kill_rx }`
2934    /// terminates when the kill channel fires, and the terminal exit
2935    /// code reflects signal termination (non-zero / None).
2936    ///
2937    /// Spawns `sleep` directly rather than through `sh -c` so SIGKILL
2938    /// reaches the process whose pipe our reader futures hold —
2939    /// `sh -c sleep` leaks the sleep child on SIGKILL (Q-C2), the
2940    /// pipe stays open, and the reader future hangs. That's a
2941    /// deliberate known limitation of start_kill; this test
2942    /// exercises the clean path.
2943    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2944    async fn kill_via_oneshot_terminates_long_running_child() {
2945        let mut cmd = TokioCommand::new("sleep");
2946        cmd.args(["30"]);
2947        cmd.stdout(std::process::Stdio::piped());
2948        cmd.stderr(std::process::Stdio::piped());
2949
2950        let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
2951        let pid = child.id().expect("child has a pid");
2952
2953        let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
2954        let stdout_pipe = child.stdout.take();
2955        let stderr_pipe = child.stderr.take();
2956
2957        let stdout_fut = async {
2958            let mut buf = String::new();
2959            if let Some(s) = stdout_pipe {
2960                #[allow(clippy::let_underscore_must_use)]
2961                let _ = BufReader::new(s).read_to_string(&mut buf).await;
2962            }
2963            buf
2964        };
2965        let stderr_fut = async {
2966            let mut buf = String::new();
2967            if let Some(s) = stderr_pipe {
2968                #[allow(clippy::let_underscore_must_use)]
2969                let _ = BufReader::new(s).read_to_string(&mut buf).await;
2970            }
2971            buf
2972        };
2973        let wait_fut = async {
2974            tokio::select! {
2975                status = child.wait() => {
2976                    status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
2977                }
2978                _ = &mut kill_rx => {
2979                    #[allow(clippy::let_underscore_must_use)]
2980                    let _ = child.start_kill();
2981                    child
2982                        .wait()
2983                        .await
2984                        .map(|s| s.code().unwrap_or(-1))
2985                        .unwrap_or(-1)
2986                }
2987            }
2988        };
2989
2990        // Give the shell a moment to install itself — firing kill
2991        // against an not-yet-existent child is still valid (SIGKILL
2992        // to a zombie is a no-op) but we want to actually exercise
2993        // the running-child path.
2994        tokio::time::sleep(Duration::from_millis(50)).await;
2995        kill_tx.send(()).expect("kill channel send");
2996
2997        let result = timeout(Duration::from_secs(5), async {
2998            tokio::join!(stdout_fut, stderr_fut, wait_fut)
2999        })
3000        .await;
3001
3002        let (_stdout, _stderr, exit_code) = result.expect(
3003            "kill path must resolve within 5s — if this times out the \
3004             select! arm order or kill-then-wait logic is broken",
3005        );
3006        // The cross-platform invariant is "the child did not complete
3007        // its 30s sleep" — i.e. the exit code is non-success. Platform
3008        // specifics:
3009        //   - Unix: `start_kill()` sends SIGKILL; `ExitStatus::code()`
3010        //     returns None for signal-terminated processes, which our
3011        //     dispatcher maps to -1 via `.unwrap_or(-1)`.
3012        //   - Windows: `start_kill()` calls `TerminateProcess(..., 1)`;
3013        //     `code()` returns `Some(1)`, mapped to 1 by the same
3014        //     `.unwrap_or(-1)`.
3015        // A successful 30s sleep would yield 0 — that's the
3016        // regression case we're guarding against.
3017        assert_ne!(
3018            exit_code, 0,
3019            "killed child must exit non-success (got 0 — did the \
3020             kill arm fire too late, or did sleep somehow complete?)"
3021        );
3022
3023        // Sanity: on Unix the child must be gone. `kill -0 <pid>`
3024        // returns 0 iff the process still exists; we expect non-zero
3025        // (No such process) after wait(). This catches a zombie /
3026        // leaked child that would indicate we skipped the wait() on
3027        // the kill path. Skipped on Windows — `kill` isn't available
3028        // and `tasklist` output parsing is more noise than signal
3029        // for this one-shot check; the wait() having returned is
3030        // already evidence of reap there.
3031        #[cfg(unix)]
3032        {
3033            let still_alive = std::process::Command::new("kill")
3034                .args(["-0", &pid.to_string()])
3035                .status()
3036                .map(|s| s.success())
3037                .unwrap_or(false);
3038            assert!(
3039                !still_alive,
3040                "process {pid} must be reaped after wait() — a still-\
3041                 alive check means the kill path leaked the child"
3042            );
3043        }
3044        #[cfg(not(unix))]
3045        {
3046            // Touch `pid` so the unused-variable lint doesn't fire on
3047            // non-Unix builds.
3048            let _ = pid;
3049        }
3050    }
3051}