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 {
761                label,
762                prompt_type,
763                floating_overlay,
764            } => {
765                self.handle_start_prompt(label, prompt_type, floating_overlay);
766            }
767            PluginCommand::StartPromptWithInitial {
768                label,
769                prompt_type,
770                initial_value,
771                floating_overlay,
772            } => {
773                self.handle_start_prompt_with_initial(
774                    label,
775                    prompt_type,
776                    initial_value,
777                    floating_overlay,
778                );
779            }
780            PluginCommand::StartPromptAsync {
781                label,
782                initial_value,
783                callback_id,
784            } => {
785                self.handle_start_prompt_async(label, initial_value, callback_id);
786            }
787            PluginCommand::AwaitNextKey { callback_id } => {
788                self.handle_await_next_key(callback_id);
789            }
790            PluginCommand::SetKeyCaptureActive { active } => {
791                self.key_capture_active = active;
792                if !active {
793                    // Capture window closed; any leftover queued keys
794                    // were intended for the plugin and should not now
795                    // leak into the editor's normal dispatch.
796                    self.pending_key_capture_buffer.clear();
797                }
798            }
799            PluginCommand::SetPromptSuggestions { suggestions } => {
800                self.handle_set_prompt_suggestions(suggestions);
801            }
802            PluginCommand::SetPromptInputSync { sync } => {
803                if let Some(prompt) = &mut self.prompt {
804                    prompt.sync_input_on_navigate = sync;
805                }
806            }
807            PluginCommand::SetPromptTitle { title } => {
808                if let Some(prompt) = &mut self.prompt {
809                    prompt.title = title;
810                }
811            }
812
813            // ==================== Command/Mode Registration ====================
814            PluginCommand::RegisterCommand { command } => {
815                self.handle_register_command(command);
816            }
817            PluginCommand::UnregisterCommand { name } => {
818                self.handle_unregister_command(name);
819            }
820            PluginCommand::DefineMode {
821                name,
822                bindings,
823                read_only,
824                allow_text_input,
825                inherit_normal_bindings,
826                plugin_name,
827            } => {
828                self.handle_define_mode(
829                    name,
830                    bindings,
831                    read_only,
832                    allow_text_input,
833                    inherit_normal_bindings,
834                    plugin_name,
835                );
836            }
837
838            // ==================== File/Navigation Commands ====================
839            PluginCommand::OpenFileInBackground { path } => {
840                self.handle_open_file_in_background(path);
841            }
842            PluginCommand::OpenFileAtLocation { path, line, column } => {
843                return self.handle_open_file_at_location(path, line, column);
844            }
845            PluginCommand::OpenFileInSplit {
846                split_id,
847                path,
848                line,
849                column,
850            } => {
851                return self.handle_open_file_in_split(split_id, path, line, column);
852            }
853            PluginCommand::ShowBuffer { buffer_id } => {
854                self.handle_show_buffer(buffer_id);
855            }
856            PluginCommand::CloseBuffer { buffer_id } => {
857                self.handle_close_buffer(buffer_id);
858            }
859
860            // ==================== Animation Commands ====================
861            PluginCommand::StartAnimationArea { id, rect, kind } => {
862                self.handle_start_animation_area(id, rect, kind);
863            }
864            PluginCommand::StartAnimationVirtualBuffer {
865                id,
866                buffer_id,
867                kind,
868            } => {
869                self.handle_start_animation_virtual_buffer(id, buffer_id, kind);
870            }
871            PluginCommand::CancelAnimation { id } => {
872                self.animations
873                    .cancel(crate::view::animation::AnimationId::from_raw(id));
874            }
875
876            // ==================== LSP Commands ====================
877            PluginCommand::SendLspRequest {
878                language,
879                method,
880                params,
881                request_id,
882            } => {
883                self.handle_send_lsp_request(language, method, params, request_id);
884            }
885
886            // ==================== Clipboard Commands ====================
887            PluginCommand::SetClipboard { text } => {
888                self.handle_set_clipboard(text);
889            }
890
891            // ==================== Async Plugin Commands ====================
892            PluginCommand::SpawnProcess {
893                command,
894                args,
895                cwd,
896                callback_id,
897            } => {
898                self.handle_spawn_process(command, args, cwd, callback_id);
899            }
900
901            PluginCommand::SpawnHostProcess {
902                command,
903                args,
904                cwd,
905                callback_id,
906            } => {
907                self.handle_spawn_host_process(command, args, cwd, callback_id);
908            }
909
910            PluginCommand::KillHostProcess { process_id } => {
911                self.handle_kill_host_process(process_id);
912            }
913
914            PluginCommand::SetAuthority { payload } => {
915                self.handle_set_authority(payload);
916            }
917
918            PluginCommand::ClearAuthority => {
919                tracing::info!("Plugin cleared authority; restoring local");
920                self.clear_authority();
921            }
922
923            PluginCommand::SetRemoteIndicatorState { state } => {
924                self.handle_set_remote_indicator_state(state);
925            }
926
927            PluginCommand::ClearRemoteIndicatorState => {
928                self.remote_indicator_override = None;
929            }
930
931            PluginCommand::SpawnProcessWait {
932                process_id,
933                callback_id,
934            } => {
935                self.handle_spawn_process_wait(process_id, callback_id);
936            }
937
938            PluginCommand::Delay {
939                callback_id,
940                duration_ms,
941            } => {
942                self.handle_delay(callback_id, duration_ms);
943            }
944
945            PluginCommand::SpawnBackgroundProcess {
946                process_id,
947                command,
948                args,
949                cwd,
950                callback_id,
951            } => {
952                self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
953            }
954
955            PluginCommand::KillBackgroundProcess { process_id } => {
956                self.handle_kill_background_process(process_id);
957            }
958
959            // ==================== Virtual Buffer Commands (complex, kept inline) ====================
960            PluginCommand::CreateVirtualBuffer {
961                name,
962                mode,
963                read_only,
964            } => {
965                self.handle_create_virtual_buffer(name, mode, read_only);
966            }
967            PluginCommand::CreateVirtualBufferWithContent {
968                name,
969                mode,
970                read_only,
971                entries,
972                show_line_numbers,
973                show_cursors,
974                editing_disabled,
975                hidden_from_tabs,
976                request_id,
977            } => {
978                self.handle_create_virtual_buffer_with_content(
979                    name,
980                    mode,
981                    read_only,
982                    entries,
983                    show_line_numbers,
984                    show_cursors,
985                    editing_disabled,
986                    hidden_from_tabs,
987                    request_id,
988                );
989            }
990            PluginCommand::CreateVirtualBufferInSplit {
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                role,
1004                request_id,
1005            } => {
1006                self.handle_create_virtual_buffer_in_split(
1007                    name,
1008                    mode,
1009                    read_only,
1010                    entries,
1011                    ratio,
1012                    direction,
1013                    panel_id,
1014                    show_line_numbers,
1015                    show_cursors,
1016                    editing_disabled,
1017                    line_wrap,
1018                    before,
1019                    role,
1020                    request_id,
1021                );
1022            }
1023            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1024                self.handle_set_virtual_buffer_content(buffer_id, entries);
1025            }
1026            PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1027                self.handle_get_text_properties_at_cursor(buffer_id);
1028            }
1029            PluginCommand::CreateVirtualBufferInExistingSplit {
1030                name,
1031                mode,
1032                read_only,
1033                entries,
1034                split_id,
1035                show_line_numbers,
1036                show_cursors,
1037                editing_disabled,
1038                line_wrap,
1039                request_id,
1040            } => {
1041                self.handle_create_virtual_buffer_in_existing_split(
1042                    name,
1043                    mode,
1044                    read_only,
1045                    entries,
1046                    split_id,
1047                    show_line_numbers,
1048                    show_cursors,
1049                    editing_disabled,
1050                    line_wrap,
1051                    request_id,
1052                );
1053            }
1054
1055            // ==================== Context Commands ====================
1056            PluginCommand::SetContext { name, active } => {
1057                self.handle_set_context(name, active);
1058            }
1059
1060            // ==================== Review Diff Commands ====================
1061            PluginCommand::SetReviewDiffHunks { hunks } => {
1062                self.review_hunks = hunks;
1063                tracing::debug!("Set {} review hunks", self.review_hunks.len());
1064            }
1065
1066            // ==================== Vi Mode Commands ====================
1067            PluginCommand::ExecuteAction { action_name } => {
1068                self.handle_execute_action(action_name);
1069            }
1070            PluginCommand::ExecuteActions { actions } => {
1071                self.handle_execute_actions(actions);
1072            }
1073            PluginCommand::GetBufferText {
1074                buffer_id,
1075                start,
1076                end,
1077                request_id,
1078            } => {
1079                self.handle_get_buffer_text(buffer_id, start, end, request_id);
1080            }
1081            PluginCommand::GetLineStartPosition {
1082                buffer_id,
1083                line,
1084                request_id,
1085            } => {
1086                self.handle_get_line_start_position(buffer_id, line, request_id);
1087            }
1088            PluginCommand::GetLineEndPosition {
1089                buffer_id,
1090                line,
1091                request_id,
1092            } => {
1093                self.handle_get_line_end_position(buffer_id, line, request_id);
1094            }
1095            PluginCommand::GetBufferLineCount {
1096                buffer_id,
1097                request_id,
1098            } => {
1099                self.handle_get_buffer_line_count(buffer_id, request_id);
1100            }
1101            PluginCommand::ScrollToLineCenter {
1102                split_id,
1103                buffer_id,
1104                line,
1105            } => {
1106                self.handle_scroll_to_line_center(split_id, buffer_id, line);
1107            }
1108            PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1109                self.handle_scroll_buffer_to_line(buffer_id, line);
1110            }
1111            PluginCommand::SetEditorMode { mode } => {
1112                self.handle_set_editor_mode(mode);
1113            }
1114
1115            // ==================== LSP Helper Commands ====================
1116            PluginCommand::ShowActionPopup {
1117                popup_id,
1118                title,
1119                message,
1120                actions,
1121            } => {
1122                self.handle_show_action_popup(popup_id, title, message, actions);
1123            }
1124
1125            PluginCommand::DisableLspForLanguage { language } => {
1126                self.handle_disable_lsp_for_language(language);
1127            }
1128
1129            PluginCommand::RestartLspForLanguage { language } => {
1130                self.handle_restart_lsp_for_language(language);
1131            }
1132
1133            PluginCommand::SetLspRootUri { language, uri } => {
1134                self.handle_set_lsp_root_uri(language, uri);
1135            }
1136
1137            // ==================== Scroll Sync Commands ====================
1138            PluginCommand::CreateScrollSyncGroup {
1139                group_id,
1140                left_split,
1141                right_split,
1142            } => {
1143                self.handle_create_scroll_sync_group(group_id, left_split, right_split);
1144            }
1145            PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1146                self.handle_set_scroll_sync_anchors(group_id, anchors);
1147            }
1148            PluginCommand::RemoveScrollSyncGroup { group_id } => {
1149                self.handle_remove_scroll_sync_group(group_id);
1150            }
1151
1152            // ==================== Composite Buffer Commands ====================
1153            PluginCommand::CreateCompositeBuffer {
1154                name,
1155                mode,
1156                layout,
1157                sources,
1158                hunks,
1159                initial_focus_hunk,
1160                request_id,
1161            } => {
1162                self.handle_create_composite_buffer(
1163                    name,
1164                    mode,
1165                    layout,
1166                    sources,
1167                    hunks,
1168                    initial_focus_hunk,
1169                    request_id,
1170                );
1171            }
1172            PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1173                self.handle_update_composite_alignment(buffer_id, hunks);
1174            }
1175            PluginCommand::CloseCompositeBuffer { buffer_id } => {
1176                self.close_composite_buffer(buffer_id);
1177            }
1178            PluginCommand::FlushLayout => {
1179                self.flush_layout();
1180            }
1181            PluginCommand::CompositeNextHunk { buffer_id } => {
1182                let split_id = self.split_manager.active_split();
1183                self.composite_next_hunk(split_id, buffer_id);
1184            }
1185            PluginCommand::CompositePrevHunk { buffer_id } => {
1186                let split_id = self.split_manager.active_split();
1187                self.composite_prev_hunk(split_id, buffer_id);
1188            }
1189
1190            // ==================== Buffer Groups ====================
1191            PluginCommand::CreateBufferGroup {
1192                name,
1193                mode,
1194                layout_json,
1195                request_id,
1196            } => {
1197                self.handle_create_buffer_group(name, mode, layout_json, request_id);
1198            }
1199            PluginCommand::SetPanelContent {
1200                group_id,
1201                panel_name,
1202                entries,
1203            } => {
1204                self.set_panel_content(group_id, panel_name, entries);
1205            }
1206            PluginCommand::CloseBufferGroup { group_id } => {
1207                self.close_buffer_group(group_id);
1208            }
1209            PluginCommand::FocusPanel {
1210                group_id,
1211                panel_name,
1212            } => {
1213                self.focus_panel(group_id, panel_name);
1214            }
1215
1216            // ==================== File Operations ====================
1217            PluginCommand::SaveBufferToPath { buffer_id, path } => {
1218                self.handle_save_buffer_to_path(buffer_id, path);
1219            }
1220
1221            // ==================== Plugin Management ====================
1222            #[cfg(feature = "plugins")]
1223            PluginCommand::LoadPlugin { path, callback_id } => {
1224                self.handle_load_plugin(path, callback_id);
1225            }
1226            #[cfg(feature = "plugins")]
1227            PluginCommand::UnloadPlugin { name, callback_id } => {
1228                self.handle_unload_plugin(name, callback_id);
1229            }
1230            #[cfg(feature = "plugins")]
1231            PluginCommand::ReloadPlugin { name, callback_id } => {
1232                self.handle_reload_plugin(name, callback_id);
1233            }
1234            #[cfg(feature = "plugins")]
1235            PluginCommand::ListPlugins { callback_id } => {
1236                self.handle_list_plugins(callback_id);
1237            }
1238            // When plugins feature is disabled, these commands are no-ops
1239            #[cfg(not(feature = "plugins"))]
1240            PluginCommand::LoadPlugin { .. }
1241            | PluginCommand::UnloadPlugin { .. }
1242            | PluginCommand::ReloadPlugin { .. }
1243            | PluginCommand::ListPlugins { .. } => {
1244                tracing::warn!("Plugin management commands require the 'plugins' feature");
1245            }
1246
1247            // ==================== Terminal Commands ====================
1248            PluginCommand::CreateTerminal {
1249                cwd,
1250                direction,
1251                ratio,
1252                focus,
1253                persistent,
1254                request_id,
1255            } => {
1256                self.handle_create_terminal(cwd, direction, ratio, focus, persistent, request_id);
1257            }
1258
1259            PluginCommand::SendTerminalInput { terminal_id, data } => {
1260                self.handle_send_terminal_input(terminal_id, data);
1261            }
1262
1263            PluginCommand::CloseTerminal { terminal_id } => {
1264                self.handle_close_terminal(terminal_id);
1265            }
1266
1267            PluginCommand::GrepProject {
1268                pattern,
1269                fixed_string,
1270                case_sensitive,
1271                max_results,
1272                whole_words,
1273                callback_id,
1274            } => {
1275                self.handle_grep_project(
1276                    pattern,
1277                    fixed_string,
1278                    case_sensitive,
1279                    max_results,
1280                    whole_words,
1281                    callback_id,
1282                );
1283            }
1284
1285            PluginCommand::GrepProjectStreaming {
1286                pattern,
1287                fixed_string,
1288                case_sensitive,
1289                max_results,
1290                whole_words,
1291                search_id,
1292                callback_id,
1293            } => {
1294                self.handle_grep_project_streaming(
1295                    pattern,
1296                    fixed_string,
1297                    case_sensitive,
1298                    max_results,
1299                    whole_words,
1300                    search_id,
1301                    callback_id,
1302                );
1303            }
1304
1305            PluginCommand::ReplaceInBuffer {
1306                file_path,
1307                matches,
1308                replacement,
1309                callback_id,
1310            } => {
1311                self.handle_replace_in_buffer(file_path, matches, replacement, callback_id);
1312            }
1313        }
1314        Ok(())
1315    }
1316
1317    /// Save a buffer to a specific file path (for :w filename)
1318    fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1319        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1320            // Save to the specified path
1321            match state.buffer.save_to_file(&path) {
1322                Ok(()) => {
1323                    // save_to_file already updates file_path internally via finalize_save
1324                    // Run on-save actions (formatting, etc.)
1325                    if let Err(e) = self.finalize_save(Some(path)) {
1326                        tracing::warn!("Failed to finalize save: {}", e);
1327                    }
1328                    tracing::debug!("Saved buffer {:?} to path", buffer_id);
1329                }
1330                Err(e) => {
1331                    self.handle_set_status(format!("Error saving: {}", e));
1332                    tracing::error!("Failed to save buffer to path: {}", e);
1333                }
1334            }
1335        } else {
1336            self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1337            tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1338        }
1339    }
1340
1341    /// Load a plugin from a file path
1342    #[cfg(feature = "plugins")]
1343    fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1344        match self.plugin_manager.load_plugin(&path) {
1345            Ok(()) => {
1346                tracing::info!("Loaded plugin from {:?}", path);
1347                self.plugin_manager
1348                    .resolve_callback(callback_id, "true".to_string());
1349            }
1350            Err(e) => {
1351                tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1352                self.plugin_manager
1353                    .reject_callback(callback_id, format!("{}", e));
1354            }
1355        }
1356    }
1357
1358    /// Unload a plugin by name
1359    #[cfg(feature = "plugins")]
1360    fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1361        match self.plugin_manager.unload_plugin(&name) {
1362            Ok(()) => {
1363                tracing::info!("Unloaded plugin: {}", name);
1364                self.plugin_manager
1365                    .resolve_callback(callback_id, "true".to_string());
1366            }
1367            Err(e) => {
1368                tracing::error!("Failed to unload plugin '{}': {}", name, e);
1369                self.plugin_manager
1370                    .reject_callback(callback_id, format!("{}", e));
1371            }
1372        }
1373    }
1374
1375    /// Reload a plugin by name
1376    #[cfg(feature = "plugins")]
1377    fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1378        match self.plugin_manager.reload_plugin(&name) {
1379            Ok(()) => {
1380                tracing::info!("Reloaded plugin: {}", name);
1381                self.plugin_manager
1382                    .resolve_callback(callback_id, "true".to_string());
1383            }
1384            Err(e) => {
1385                tracing::error!("Failed to reload plugin '{}': {}", name, e);
1386                self.plugin_manager
1387                    .reject_callback(callback_id, format!("{}", e));
1388            }
1389        }
1390    }
1391
1392    /// List all loaded plugins
1393    #[cfg(feature = "plugins")]
1394    fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
1395        let plugins = self.plugin_manager.list_plugins();
1396        // Serialize to JSON array of { name, path, enabled }
1397        let json_array: Vec<serde_json::Value> = plugins
1398            .iter()
1399            .map(|p| {
1400                serde_json::json!({
1401                    "name": p.name,
1402                    "path": p.path.to_string_lossy(),
1403                    "enabled": p.enabled
1404                })
1405            })
1406            .collect();
1407        let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
1408        self.plugin_manager.resolve_callback(callback_id, json_str);
1409    }
1410
1411    /// Execute an editor action by name (for vi mode plugin)
1412    fn handle_execute_action(&mut self, action_name: String) {
1413        use crate::input::keybindings::Action;
1414        use std::collections::HashMap;
1415
1416        // Parse the action name into an Action enum
1417        if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
1418            // Execute the action
1419            if let Err(e) = self.handle_action(action) {
1420                tracing::warn!("Failed to execute action '{}': {}", action_name, e);
1421            } else {
1422                tracing::debug!("Executed action: {}", action_name);
1423            }
1424        } else {
1425            tracing::warn!("Unknown action: {}", action_name);
1426        }
1427    }
1428
1429    /// Execute multiple actions in sequence, each with an optional repeat count
1430    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1431    fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
1432        use crate::input::keybindings::Action;
1433        use std::collections::HashMap;
1434
1435        for action_spec in actions {
1436            if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
1437                // Execute the action `count` times
1438                for _ in 0..action_spec.count {
1439                    if let Err(e) = self.handle_action(action.clone()) {
1440                        tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
1441                        return; // Stop on first error
1442                    }
1443                }
1444                tracing::debug!(
1445                    "Executed action '{}' {} time(s)",
1446                    action_spec.action,
1447                    action_spec.count
1448                );
1449            } else {
1450                tracing::warn!("Unknown action: {}", action_spec.action);
1451                return; // Stop on unknown action
1452            }
1453        }
1454    }
1455
1456    /// Get text from a buffer range (for vi mode yank operations)
1457    fn handle_get_buffer_text(
1458        &mut self,
1459        buffer_id: BufferId,
1460        start: usize,
1461        end: usize,
1462        request_id: u64,
1463    ) {
1464        let result = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1465            // Get text from the buffer using the mutable get_text_range method
1466            let len = state.buffer.len();
1467            if start <= end && end <= len {
1468                Ok(state.get_text_range(start, end))
1469            } else {
1470                Err(format!(
1471                    "Invalid range {}..{} for buffer of length {}",
1472                    start, end, len
1473                ))
1474            }
1475        } else {
1476            Err(format!("Buffer {:?} not found", buffer_id))
1477        };
1478
1479        // Resolve the JavaScript Promise callback directly
1480        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1481        match result {
1482            Ok(text) => {
1483                // Serialize text as JSON string
1484                let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
1485                self.plugin_manager.resolve_callback(callback_id, json);
1486            }
1487            Err(error) => {
1488                self.plugin_manager.reject_callback(callback_id, error);
1489            }
1490        }
1491    }
1492
1493    /// Set the global editor mode (for vi mode)
1494    fn handle_set_editor_mode(&mut self, mode: Option<String>) {
1495        self.editor_mode = mode.clone();
1496        tracing::debug!("Set editor mode: {:?}", mode);
1497    }
1498
1499    /// Normalize a plugin-supplied `BufferId`: treat id 0 as "use the active buffer".
1500    fn resolve_buffer_id(&self, buffer_id: BufferId) -> BufferId {
1501        if buffer_id.0 == 0 {
1502            self.active_buffer()
1503        } else {
1504            buffer_id
1505        }
1506    }
1507
1508    /// Serialize `value` as JSON and resolve `request_id` as a JS Promise callback.
1509    fn resolve_json_callback<T: serde::Serialize>(&mut self, request_id: u64, value: T) {
1510        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1511        let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1512        self.plugin_manager.resolve_callback(callback_id, json);
1513    }
1514
1515    /// Get the byte offset of the start of a line in the active buffer
1516    fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1517        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1518        let result = self.buffers.get_mut(&actual_buffer_id).and_then(|state| {
1519            let len = state.buffer.len();
1520            let content = state.get_text_range(0, len);
1521            buffer_line_byte_offset(&content, len, line as usize, false)
1522        });
1523        self.resolve_json_callback(request_id, result);
1524    }
1525
1526    /// Get the byte offset of the end of a line (position of its terminating newline,
1527    /// or `buffer_len` for the last line without a trailing newline).
1528    fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1529        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1530        let result = self.buffers.get_mut(&actual_buffer_id).and_then(|state| {
1531            let len = state.buffer.len();
1532            let content = state.get_text_range(0, len);
1533            buffer_line_byte_offset(&content, len, line as usize, true)
1534        });
1535        self.resolve_json_callback(request_id, result);
1536    }
1537
1538    /// Get the total number of lines in a buffer
1539    fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
1540        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1541
1542        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
1543            let buffer_len = state.buffer.len();
1544            let content = state.get_text_range(0, buffer_len);
1545
1546            // Count lines (number of newlines + 1, unless empty)
1547            if content.is_empty() {
1548                Some(1) // Empty buffer has 1 line
1549            } else {
1550                let newline_count = content.chars().filter(|&c| c == '\n').count();
1551                // If file ends with newline, don't count extra line
1552                let ends_with_newline = content.ends_with('\n');
1553                if ends_with_newline {
1554                    Some(newline_count)
1555                } else {
1556                    Some(newline_count + 1)
1557                }
1558            }
1559        } else {
1560            None
1561        };
1562
1563        self.resolve_json_callback(request_id, result);
1564    }
1565
1566    /// Scroll a split to center a specific line in the viewport
1567    fn handle_scroll_to_line_center(
1568        &mut self,
1569        split_id: SplitId,
1570        buffer_id: BufferId,
1571        line: usize,
1572    ) {
1573        let actual_split_id = if split_id.0 == 0 {
1574            self.split_manager.active_split()
1575        } else {
1576            LeafId(split_id)
1577        };
1578        let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1579
1580        // Get viewport height
1581        let viewport_height = if let Some(view_state) = self.split_view_states.get(&actual_split_id)
1582        {
1583            view_state.viewport.height as usize
1584        } else {
1585            return;
1586        };
1587
1588        // Calculate the target line to scroll to (center the requested line)
1589        let lines_above = viewport_height / 2;
1590        let target_line = line.saturating_sub(lines_above);
1591
1592        // Get the buffer and scroll
1593        if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
1594            let buffer = &mut state.buffer;
1595            if let Some(view_state) = self.split_view_states.get_mut(&actual_split_id) {
1596                view_state.viewport.scroll_to(buffer, target_line);
1597                // Mark to skip ensure_visible on next render so the scroll isn't undone
1598                view_state.viewport.set_skip_ensure_visible();
1599            }
1600        }
1601    }
1602
1603    /// Scroll every split whose active buffer is `buffer_id` so that
1604    /// `line` is within the viewport. Used by plugin panels (buffer
1605    /// groups) whose plugin-side "selected row" doesn't drive the
1606    /// buffer cursor — after updating the selection, the plugin calls
1607    /// this to bring the selected row into view.
1608    ///
1609    /// Walks both the main split tree's leaves AND the inner leaves of
1610    /// all Grouped subtrees stored in `grouped_subtrees`, because the
1611    /// latter are not represented in `split_manager`'s tree.
1612    fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
1613        if !self.buffers.contains_key(&buffer_id) {
1614            return;
1615        }
1616
1617        // Collect the leaf ids whose active buffer is `buffer_id`.
1618        let mut target_leaves: Vec<LeafId> = Vec::new();
1619
1620        // Main tree: walk its leaves.
1621        for leaf_id in self.split_manager.root().leaf_split_ids() {
1622            if let Some(vs) = self.split_view_states.get(&leaf_id) {
1623                if vs.active_buffer == buffer_id {
1624                    target_leaves.push(leaf_id);
1625                }
1626            }
1627        }
1628
1629        // Grouped subtrees: walk each group's inner leaves.
1630        for (_group_leaf_id, node) in self.grouped_subtrees.iter() {
1631            if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
1632                for inner_leaf in layout.leaf_split_ids() {
1633                    if let Some(vs) = self.split_view_states.get(&inner_leaf) {
1634                        if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
1635                            target_leaves.push(inner_leaf);
1636                        }
1637                    }
1638                }
1639            }
1640        }
1641
1642        if target_leaves.is_empty() {
1643            return;
1644        }
1645
1646        let state = match self.buffers.get_mut(&buffer_id) {
1647            Some(s) => s,
1648            None => return,
1649        };
1650
1651        for leaf_id in target_leaves {
1652            let Some(view_state) = self.split_view_states.get_mut(&leaf_id) else {
1653                continue;
1654            };
1655            let viewport_height = view_state.viewport.height as usize;
1656            // Place `line` roughly a third of the viewport from the top so
1657            // the next few navigation steps don't immediately scroll again.
1658            let lines_above = viewport_height / 3;
1659            let target = line.saturating_sub(lines_above);
1660            view_state.viewport.scroll_to(&mut state.buffer, target);
1661            view_state.viewport.set_skip_ensure_visible();
1662        }
1663    }
1664
1665    fn handle_spawn_host_process(
1666        &mut self,
1667        command: String,
1668        args: Vec<String>,
1669        cwd: Option<String>,
1670        callback_id: JsCallbackId,
1671    ) {
1672        // Bypass the active authority on purpose: this is
1673        // reserved for plugin internals that must run host-side
1674        // work (e.g. `devcontainer up`) before the authority
1675        // they want is even built. Uses the same callback shape
1676        // as `SpawnProcess` so the plugin-facing API is
1677        // symmetric.
1678        //
1679        // Kill handle: we store a oneshot sender in
1680        // `host_process_handles` keyed by the callback id. A
1681        // `KillHostProcess` dispatch sends on it; the spawn
1682        // task's `tokio::select!` then start_kill()s the
1683        // child. This lets a plugin cancel a long-running
1684        // spawn (e.g. "Cancel Startup" on the Remote
1685        // Indicator popup during `devcontainer up`).
1686        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1687            use tokio::io::{AsyncReadExt, BufReader};
1688            use tokio::process::Command as TokioCommand;
1689
1690            let effective_cwd = cwd.or_else(|| {
1691                std::env::current_dir()
1692                    .map(|p| p.to_string_lossy().to_string())
1693                    .ok()
1694            });
1695            let sender = bridge.sender();
1696            let process_id = callback_id.as_u64();
1697
1698            let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
1699            self.host_process_handles.insert(process_id, kill_tx);
1700
1701            runtime.spawn(async move {
1702                use crate::services::process_hidden::HideWindow;
1703                let mut cmd = TokioCommand::new(&command);
1704                cmd.args(&args);
1705                cmd.stdout(std::process::Stdio::piped());
1706                cmd.stderr(std::process::Stdio::piped());
1707                cmd.hide_window();
1708                if let Some(ref dir) = effective_cwd {
1709                    cmd.current_dir(dir);
1710                }
1711                let mut child = match cmd.spawn() {
1712                    Ok(c) => c,
1713                    Err(e) => {
1714                        #[allow(clippy::let_underscore_must_use)]
1715                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
1716                            process_id,
1717                            stdout: String::new(),
1718                            stderr: e.to_string(),
1719                            exit_code: -1,
1720                        });
1721                        return;
1722                    }
1723                };
1724
1725                // Take the pipes out of the Child so the
1726                // reader tasks own them; then `child.wait()`
1727                // has exclusive mutable access for the
1728                // kill-or-exit select. Matches the
1729                // fresh-plugin-runtime process.rs pattern.
1730                let stdout_pipe = child.stdout.take();
1731                let stderr_pipe = child.stderr.take();
1732
1733                let stdout_fut = async {
1734                    let mut buf = String::new();
1735                    if let Some(s) = stdout_pipe {
1736                        #[allow(clippy::let_underscore_must_use)]
1737                        let _ = BufReader::new(s).read_to_string(&mut buf).await;
1738                    }
1739                    buf
1740                };
1741                let stderr_fut = async {
1742                    let mut buf = String::new();
1743                    if let Some(s) = stderr_pipe {
1744                        #[allow(clippy::let_underscore_must_use)]
1745                        let _ = BufReader::new(s).read_to_string(&mut buf).await;
1746                    }
1747                    buf
1748                };
1749                let wait_fut = async {
1750                    tokio::select! {
1751                        status = child.wait() => {
1752                            status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
1753                        }
1754                        _ = &mut kill_rx => {
1755                            // Best-effort SIGKILL + reap.
1756                            // Children of the killed
1757                            // process may leak (Q-C2).
1758                            #[allow(clippy::let_underscore_must_use)]
1759                            let _ = child.start_kill();
1760                            child
1761                                .wait()
1762                                .await
1763                                .map(|s| s.code().unwrap_or(-1))
1764                                .unwrap_or(-1)
1765                        }
1766                    }
1767                };
1768                let (stdout, stderr, exit_code) = tokio::join!(stdout_fut, stderr_fut, wait_fut);
1769
1770                #[allow(clippy::let_underscore_must_use)]
1771                let _ = sender.send(AsyncMessage::PluginProcessOutput {
1772                    process_id,
1773                    stdout,
1774                    stderr,
1775                    exit_code,
1776                });
1777            });
1778        } else {
1779            self.plugin_manager
1780                .reject_callback(callback_id, "Async runtime not available".to_string());
1781        }
1782    }
1783
1784    fn handle_spawn_background_process(
1785        &mut self,
1786        process_id: u64,
1787        command: String,
1788        args: Vec<String>,
1789        cwd: Option<String>,
1790        callback_id: JsCallbackId,
1791    ) {
1792        // Spawn background process with streaming output via tokio
1793        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1794            use tokio::io::{AsyncBufReadExt, BufReader};
1795            use tokio::process::Command as TokioCommand;
1796
1797            let effective_cwd = cwd.unwrap_or_else(|| {
1798                std::env::current_dir()
1799                    .map(|p| p.to_string_lossy().to_string())
1800                    .unwrap_or_else(|_| ".".to_string())
1801            });
1802
1803            let sender = bridge.sender();
1804            let sender_stdout = sender.clone();
1805            let sender_stderr = sender.clone();
1806            let callback_id_u64 = callback_id.as_u64();
1807
1808            // Receiver may be dropped if editor is shutting down
1809            #[allow(clippy::let_underscore_must_use)]
1810            let handle = runtime.spawn(async move {
1811                use crate::services::process_hidden::HideWindow;
1812                let mut child = match TokioCommand::new(&command)
1813                    .args(&args)
1814                    .current_dir(&effective_cwd)
1815                    .stdout(std::process::Stdio::piped())
1816                    .stderr(std::process::Stdio::piped())
1817                    .hide_window()
1818                    .spawn()
1819                {
1820                    Ok(child) => child,
1821                    Err(e) => {
1822                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1823                            fresh_core::api::PluginAsyncMessage::ProcessExit {
1824                                process_id,
1825                                callback_id: callback_id_u64,
1826                                exit_code: -1,
1827                            },
1828                        ));
1829                        tracing::error!("Failed to spawn background process: {}", e);
1830                        return;
1831                    }
1832                };
1833
1834                // Stream stdout
1835                let stdout = child.stdout.take();
1836                let stderr = child.stderr.take();
1837                let pid = process_id;
1838
1839                // Spawn stdout reader
1840                if let Some(stdout) = stdout {
1841                    let sender = sender_stdout;
1842                    tokio::spawn(async move {
1843                        let reader = BufReader::new(stdout);
1844                        let mut lines = reader.lines();
1845                        while let Ok(Some(line)) = lines.next_line().await {
1846                            let _ =
1847                                sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1848                                    fresh_core::api::PluginAsyncMessage::ProcessStdout {
1849                                        process_id: pid,
1850                                        data: line + "\n",
1851                                    },
1852                                ));
1853                        }
1854                    });
1855                }
1856
1857                // Spawn stderr reader
1858                if let Some(stderr) = stderr {
1859                    let sender = sender_stderr;
1860                    tokio::spawn(async move {
1861                        let reader = BufReader::new(stderr);
1862                        let mut lines = reader.lines();
1863                        while let Ok(Some(line)) = lines.next_line().await {
1864                            let _ =
1865                                sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1866                                    fresh_core::api::PluginAsyncMessage::ProcessStderr {
1867                                        process_id: pid,
1868                                        data: line + "\n",
1869                                    },
1870                                ));
1871                        }
1872                    });
1873                }
1874
1875                // Wait for process to complete
1876                let exit_code = match child.wait().await {
1877                    Ok(status) => status.code().unwrap_or(-1),
1878                    Err(_) => -1,
1879                };
1880
1881                let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1882                    fresh_core::api::PluginAsyncMessage::ProcessExit {
1883                        process_id,
1884                        callback_id: callback_id_u64,
1885                        exit_code,
1886                    },
1887                ));
1888            });
1889
1890            // Store abort handle for potential kill
1891            self.background_process_handles
1892                .insert(process_id, handle.abort_handle());
1893        } else {
1894            // No runtime - reject immediately
1895            self.plugin_manager
1896                .reject_callback(callback_id, "Async runtime not available".to_string());
1897        }
1898    }
1899
1900    fn handle_create_virtual_buffer_with_content(
1901        &mut self,
1902        name: String,
1903        mode: String,
1904        read_only: bool,
1905        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1906        show_line_numbers: bool,
1907        show_cursors: bool,
1908        editing_disabled: bool,
1909        hidden_from_tabs: bool,
1910        request_id: Option<u64>,
1911    ) {
1912        let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1913        tracing::info!(
1914            "Created virtual buffer '{}' with mode '{}' (id={:?})",
1915            name,
1916            mode,
1917            buffer_id
1918        );
1919
1920        // Apply view options to the buffer
1921        // TODO: show_line_numbers is duplicated between EditorState.margins and
1922        // BufferViewState. The renderer reads BufferViewState and overwrites
1923        // margins each frame via configure_for_line_numbers(), making the margin
1924        // setting here effectively write-only. Consider removing the margin call
1925        // and only setting BufferViewState.show_line_numbers.
1926        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1927            state.margins.configure_for_line_numbers(show_line_numbers);
1928            state.show_cursors = show_cursors;
1929            state.editing_disabled = editing_disabled;
1930            tracing::debug!(
1931                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
1932                        buffer_id,
1933                        show_line_numbers,
1934                        show_cursors,
1935                        editing_disabled
1936                    );
1937        }
1938        let active_split = self.split_manager.active_split();
1939        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1940            view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
1941        }
1942
1943        // Apply hidden_from_tabs to buffer metadata
1944        if hidden_from_tabs {
1945            if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
1946                meta.hidden_from_tabs = true;
1947            }
1948        }
1949
1950        // Now set the content
1951        match self.set_virtual_buffer_content(buffer_id, entries) {
1952            Ok(()) => {
1953                tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
1954                // Switch to the new buffer to display it
1955                self.set_active_buffer(buffer_id);
1956                tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
1957
1958                // Send response if request_id is present
1959                if let Some(req_id) = request_id {
1960                    tracing::info!(
1961                                "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
1962                                req_id,
1963                                buffer_id
1964                            );
1965                    // createVirtualBuffer returns VirtualBufferResult: { bufferId, splitId }
1966                    let result = fresh_core::api::VirtualBufferResult {
1967                        buffer_id: buffer_id.0 as u64,
1968                        split_id: None,
1969                    };
1970                    self.plugin_manager.resolve_callback(
1971                        fresh_core::api::JsCallbackId::from(req_id),
1972                        serde_json::to_string(&result).unwrap_or_default(),
1973                    );
1974                    tracing::info!(
1975                        "CreateVirtualBufferWithContent: resolve_callback sent for request_id={}",
1976                        req_id
1977                    );
1978                }
1979            }
1980            Err(e) => {
1981                tracing::error!("Failed to set virtual buffer content: {}", e);
1982            }
1983        }
1984    }
1985
1986    fn handle_create_virtual_buffer_in_split(
1987        &mut self,
1988        name: String,
1989        mode: String,
1990        read_only: bool,
1991        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1992        ratio: f32,
1993        direction: Option<String>,
1994        panel_id: Option<String>,
1995        show_line_numbers: bool,
1996        show_cursors: bool,
1997        editing_disabled: bool,
1998        line_wrap: Option<bool>,
1999        before: bool,
2000        role: Option<String>,
2001        request_id: Option<u64>,
2002    ) {
2003        // Resolve the role string. Unknown roles are silently dropped
2004        // (forward-compat for plugins targeting newer cores).
2005        let split_role: Option<crate::view::split::SplitRole> = match role.as_deref() {
2006            Some("utility_dock") => Some(crate::view::split::SplitRole::UtilityDock),
2007            _ => None,
2008        };
2009
2010        // Utility-dock fast path (issue #1796 / Section 2 of the design):
2011        // if a leaf with this role already exists, swap its active
2012        // buffer instead of spawning a fresh split. The buffer is
2013        // created normally, registered in `panel_ids`, and added as a
2014        // tab in the dock leaf.
2015        if let Some(target_role) = split_role {
2016            if let Some(dock_leaf) = self.split_manager.find_leaf_by_role(target_role) {
2017                // Capture the source split *before* create_virtual_buffer
2018                // tabs the new buffer into it; we drop that phantom tab
2019                // after the dock attach so the buffer only shows in the
2020                // dock.
2021                let source_split_before_create = self.split_manager.active_split();
2022                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
2023                if let Some(state) = self.buffers.get_mut(&buffer_id) {
2024                    state.margins.configure_for_line_numbers(show_line_numbers);
2025                    state.show_cursors = show_cursors;
2026                    state.editing_disabled = editing_disabled;
2027                }
2028                if let Some(pid) = &panel_id {
2029                    self.panel_ids.insert(pid.clone(), buffer_id);
2030                }
2031                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2032                    tracing::error!("Failed to set virtual buffer content (dock route): {}", e);
2033                    return;
2034                }
2035
2036                // Swap the dock leaf's active buffer to the new one and
2037                // add it as a tab so the user can flip between
2038                // dock-resident utilities (Diagnostics ↔ Quickfix etc.).
2039                self.split_manager.set_active_split(dock_leaf);
2040                self.set_pane_buffer(dock_leaf, buffer_id);
2041
2042                // Drop the phantom tab from the source split.
2043                if dock_leaf != source_split_before_create {
2044                    if let Some(source_view_state) =
2045                        self.split_view_states.get_mut(&source_split_before_create)
2046                    {
2047                        source_view_state.remove_buffer(buffer_id);
2048                    }
2049                }
2050
2051                if let Some(req_id) = request_id {
2052                    let result = fresh_core::api::VirtualBufferResult {
2053                        buffer_id: buffer_id.0 as u64,
2054                        split_id: Some(dock_leaf.0 .0 as u64),
2055                    };
2056                    self.plugin_manager.resolve_callback(
2057                        fresh_core::api::JsCallbackId::from(req_id),
2058                        serde_json::to_string(&result).unwrap_or_default(),
2059                    );
2060                }
2061                tracing::info!(
2062                    "Routed virtual buffer '{}' into existing utility dock {:?}",
2063                    name,
2064                    dock_leaf
2065                );
2066                return;
2067            }
2068            // No dock yet — fall through to normal split creation,
2069            // then tag the new leaf with the requested role at the end.
2070        }
2071
2072        // Check if this panel already exists (for idempotent operations)
2073        if let Some(pid) = &panel_id {
2074            if let Some(&existing_buffer_id) = self.panel_ids.get(pid) {
2075                // Verify the buffer actually exists (defensive check for stale entries)
2076                if self.buffers.contains_key(&existing_buffer_id) {
2077                    // Panel exists, just update its content
2078                    if let Err(e) = self.set_virtual_buffer_content(existing_buffer_id, entries) {
2079                        tracing::error!("Failed to update panel content: {}", e);
2080                    } else {
2081                        tracing::info!("Updated existing panel '{}' content", pid);
2082                    }
2083
2084                    // Find and focus the split that contains this buffer
2085                    let splits = self.split_manager.splits_for_buffer(existing_buffer_id);
2086                    if let Some(&split_id) = splits.first() {
2087                        self.split_manager.set_active_split(split_id);
2088                        // Route through set_pane_buffer so tree + SVS
2089                        // stay consistent (issue #1620 invariant).
2090                        self.set_pane_buffer(split_id, existing_buffer_id);
2091                        tracing::debug!("Focused split {:?} containing panel buffer", split_id);
2092                    }
2093
2094                    // Send response with existing buffer ID and split ID via callback resolution
2095                    if let Some(req_id) = request_id {
2096                        let result = fresh_core::api::VirtualBufferResult {
2097                            buffer_id: existing_buffer_id.0 as u64,
2098                            split_id: splits.first().map(|s| s.0 .0 as u64),
2099                        };
2100                        self.plugin_manager.resolve_callback(
2101                            fresh_core::api::JsCallbackId::from(req_id),
2102                            serde_json::to_string(&result).unwrap_or_default(),
2103                        );
2104                    }
2105                    return;
2106                } else {
2107                    // Buffer no longer exists, remove stale panel_id entry
2108                    tracing::warn!(
2109                        "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
2110                        pid,
2111                        existing_buffer_id
2112                    );
2113                    self.panel_ids.remove(pid);
2114                    // Fall through to create a new buffer
2115                }
2116            }
2117        }
2118
2119        // Capture the source split before creating the buffer —
2120        // `create_virtual_buffer` unconditionally adds the new buffer
2121        // as a tab to the currently active split, which is the wrong
2122        // thing for a panel that lives in its own dedicated split
2123        // (it would show up as a tab in BOTH splits — see bug #3).
2124        let source_split_before_create = self.split_manager.active_split();
2125
2126        // Create the virtual buffer first
2127        let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
2128        tracing::info!(
2129            "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
2130            name,
2131            mode,
2132            buffer_id
2133        );
2134
2135        // Apply view options to the buffer
2136        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2137            state.margins.configure_for_line_numbers(show_line_numbers);
2138            state.show_cursors = show_cursors;
2139            state.editing_disabled = editing_disabled;
2140            tracing::debug!(
2141                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
2142                        buffer_id,
2143                        show_line_numbers,
2144                        show_cursors,
2145                        editing_disabled
2146                    );
2147        }
2148
2149        // Store the panel ID mapping if provided
2150        if let Some(pid) = panel_id {
2151            self.panel_ids.insert(pid, buffer_id);
2152        }
2153
2154        // Set the content
2155        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2156            tracing::error!("Failed to set virtual buffer content: {}", e);
2157            return;
2158        }
2159
2160        // Determine split direction
2161        let split_dir = match direction.as_deref() {
2162            Some("vertical") => crate::model::event::SplitDirection::Vertical,
2163            _ => crate::model::event::SplitDirection::Horizontal,
2164        };
2165
2166        // Create a split with the new buffer. When the caller asked
2167        // for `role = "utility_dock"` and no dock leaf exists yet,
2168        // split at the *root* so the dock spans the full width below
2169        // any pre-existing side-by-side panes — splitting the active
2170        // leaf would nest the dock under whichever pane was focused.
2171        let created_split_id =
2172            match if split_role == Some(crate::view::split::SplitRole::UtilityDock) {
2173                self.split_manager
2174                    .split_root_positioned(split_dir, buffer_id, ratio, before)
2175            } else {
2176                self.split_manager
2177                    .split_active_positioned(split_dir, buffer_id, ratio, before)
2178            } {
2179                Ok(new_split_id) => {
2180                    // The buffer now lives in its own split, so drop its
2181                    // tab from the source split (see bug #3).  Only do
2182                    // this when the new split actually differs from the
2183                    // source split — otherwise we'd leave no split
2184                    // displaying the buffer.
2185                    if new_split_id != source_split_before_create {
2186                        if let Some(source_view_state) =
2187                            self.split_view_states.get_mut(&source_split_before_create)
2188                        {
2189                            source_view_state.remove_buffer(buffer_id);
2190                        }
2191                    }
2192                    // Create independent view state for the new split with the buffer in tabs
2193                    let mut view_state = SplitViewState::with_buffer(
2194                        self.terminal_width,
2195                        self.terminal_height,
2196                        buffer_id,
2197                    );
2198                    view_state.apply_config_defaults(
2199                        self.config.editor.line_numbers,
2200                        self.config.editor.highlight_current_line,
2201                        line_wrap.unwrap_or_else(|| self.resolve_line_wrap_for_buffer(buffer_id)),
2202                        self.config.editor.wrap_indent,
2203                        self.resolve_wrap_column_for_buffer(buffer_id),
2204                        self.config.editor.rulers.clone(),
2205                    );
2206                    // Override with plugin-requested show_line_numbers
2207                    view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2208                    self.split_view_states.insert(new_split_id, view_state);
2209
2210                    // Focus the new split (the diagnostics panel)
2211                    self.split_manager.set_active_split(new_split_id);
2212                    // NOTE: split tree was updated by split_active, active_buffer derives from it
2213
2214                    // If a role was requested but no dock existed (we fell
2215                    // through the fast-path above), tag the freshly created
2216                    // leaf so the next utility lands here. Clear any stale
2217                    // role from elsewhere first to preserve the
2218                    // one-leaf-per-role invariant.
2219                    if let Some(target_role) = split_role {
2220                        self.split_manager.clear_role(target_role);
2221                        self.split_manager
2222                            .set_leaf_role(new_split_id, Some(target_role));
2223                        tracing::info!(
2224                            "Tagged new dock leaf {:?} with role {:?}",
2225                            new_split_id,
2226                            target_role
2227                        );
2228                    }
2229
2230                    tracing::info!(
2231                        "Created {:?} split with virtual buffer {:?}",
2232                        split_dir,
2233                        buffer_id
2234                    );
2235                    Some(new_split_id)
2236                }
2237                Err(e) => {
2238                    tracing::error!("Failed to create split: {}", e);
2239                    // Fall back to just switching to the buffer
2240                    self.set_active_buffer(buffer_id);
2241                    None
2242                }
2243            };
2244
2245        // Send response with buffer ID and split ID via callback resolution
2246        // NOTE: Using VirtualBufferResult type for type-safe JSON serialization
2247        if let Some(req_id) = request_id {
2248            tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
2249            let result = fresh_core::api::VirtualBufferResult {
2250                buffer_id: buffer_id.0 as u64,
2251                split_id: created_split_id.map(|s| s.0 .0 as u64),
2252            };
2253            self.plugin_manager.resolve_callback(
2254                fresh_core::api::JsCallbackId::from(req_id),
2255                serde_json::to_string(&result).unwrap_or_default(),
2256            );
2257        }
2258    }
2259
2260    fn handle_create_virtual_buffer_in_existing_split(
2261        &mut self,
2262        name: String,
2263        mode: String,
2264        read_only: bool,
2265        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2266        split_id: SplitId,
2267        show_line_numbers: bool,
2268        show_cursors: bool,
2269        editing_disabled: bool,
2270        line_wrap: Option<bool>,
2271        request_id: Option<u64>,
2272    ) {
2273        // Create the virtual buffer
2274        let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
2275        tracing::info!(
2276            "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
2277            name,
2278            mode,
2279            split_id,
2280            buffer_id
2281        );
2282
2283        // Apply view options to the buffer
2284        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2285            state.margins.configure_for_line_numbers(show_line_numbers);
2286            state.show_cursors = show_cursors;
2287            state.editing_disabled = editing_disabled;
2288        }
2289
2290        // Set the content
2291        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2292            tracing::error!("Failed to set virtual buffer content: {}", e);
2293            return;
2294        }
2295
2296        // Show the buffer in the target split. set_pane_buffer
2297        // covers the tree + SVS updates the old code did by hand.
2298        let leaf_id = LeafId(split_id);
2299        self.split_manager.set_active_split(leaf_id);
2300        self.set_pane_buffer(leaf_id, buffer_id);
2301
2302        // Fall-through to the cursor/open_buffers housekeeping
2303        // that used to follow the manual switch_buffer. We keep
2304        // the `if let Some(view_state)` block below — set_pane_buffer
2305        // already called switch_buffer, but the downstream code
2306        // also nudges open_buffers and focus_history.
2307        if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
2308            view_state.switch_buffer(buffer_id);
2309            view_state.add_buffer(buffer_id);
2310            view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2311
2312            // Apply line_wrap setting if provided
2313            if let Some(wrap) = line_wrap {
2314                view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
2315            }
2316        }
2317
2318        tracing::info!(
2319            "Displayed virtual buffer {:?} in split {:?}",
2320            buffer_id,
2321            split_id
2322        );
2323
2324        // Send response with buffer ID and split ID via callback resolution
2325        if let Some(req_id) = request_id {
2326            let result = fresh_core::api::VirtualBufferResult {
2327                buffer_id: buffer_id.0 as u64,
2328                split_id: Some(split_id.0 as u64),
2329            };
2330            self.plugin_manager.resolve_callback(
2331                fresh_core::api::JsCallbackId::from(req_id),
2332                serde_json::to_string(&result).unwrap_or_default(),
2333            );
2334        }
2335    }
2336
2337    fn handle_show_action_popup(
2338        &mut self,
2339        popup_id: String,
2340        title: String,
2341        message: String,
2342        actions: Vec<fresh_core::api::ActionPopupAction>,
2343    ) {
2344        tracing::info!(
2345            "Action popup requested: id={}, title={}, actions={}",
2346            popup_id,
2347            title,
2348            actions.len()
2349        );
2350
2351        // Build popup list items from actions
2352        let items: Vec<crate::model::event::PopupListItemData> = actions
2353            .iter()
2354            .map(|action| crate::model::event::PopupListItemData {
2355                text: action.label.clone(),
2356                detail: None,
2357                icon: None,
2358                data: Some(action.id.clone()),
2359            })
2360            .collect();
2361
2362        // The popup_id lives on the popup itself via its
2363        // `PopupResolver::PluginAction` — no side-channel stack.
2364        // Drop the incoming `actions` vec; its ids are already
2365        // encoded as each list item's `data` field below.
2366        drop(actions);
2367
2368        // Create popup with message + action list
2369        let popup_data = crate::model::event::PopupData {
2370            kind: crate::model::event::PopupKindHint::List,
2371            title: Some(title),
2372            description: Some(message),
2373            transient: false,
2374            content: crate::model::event::PopupContentData::List { items, selected: 0 },
2375            position: crate::model::event::PopupPositionData::BottomRight,
2376            width: 60,
2377            max_height: 15,
2378            bordered: true,
2379        };
2380
2381        // Action popups are buffer-independent notifications; route
2382        // them to the editor-level popup stack so they remain visible
2383        // (and dismissible) regardless of which buffer is focused —
2384        // including virtual buffers like the Dashboard that own the
2385        // whole split.
2386        //
2387        // The resolver carries the popup_id so confirm/cancel fires
2388        // `action_popup_result` for exactly THIS popup, even when
2389        // multiple plugin popups are stacked concurrently.
2390        let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
2391        popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
2392            popup_id: popup_id.clone(),
2393        };
2394        self.global_popups.show(popup_obj);
2395        tracing::info!(
2396            "Action popup shown: id={}, stack_depth={}",
2397            popup_id,
2398            self.global_popups.all().len()
2399        );
2400    }
2401
2402    fn handle_create_terminal(
2403        &mut self,
2404        cwd: Option<String>,
2405        direction: Option<String>,
2406        ratio: Option<f32>,
2407        focus: Option<bool>,
2408        persistent: bool,
2409        request_id: u64,
2410    ) {
2411        let (cols, rows) = self.get_terminal_dimensions();
2412
2413        // Set up async bridge for terminal manager if not already done
2414        if let Some(ref bridge) = self.async_bridge {
2415            self.terminal_manager.set_async_bridge(bridge.clone());
2416        }
2417
2418        // Determine working directory
2419        let working_dir = cwd
2420            .map(std::path::PathBuf::from)
2421            .unwrap_or_else(|| self.working_dir.clone());
2422
2423        // Prepare persistent storage paths
2424        let terminal_root = self.dir_context.terminal_dir_for(&working_dir);
2425        if let Err(e) = self.authority.filesystem.create_dir_all(&terminal_root) {
2426            tracing::warn!("Failed to create terminal directory: {}", e);
2427        }
2428        let predicted_terminal_id = self.terminal_manager.next_terminal_id();
2429        // Ephemeral terminals get a per-spawn suffix on their backing
2430        // files so there is no possibility of picking up the scrollback
2431        // that a previous run (with the same numeric terminal ID) wrote
2432        // to `fresh-terminal-N.{txt,log}`. Persistent terminals keep
2433        // the stable `fresh-terminal-N.*` name so workspace restore
2434        // can still find them.
2435        let name_stem = if persistent {
2436            format!("fresh-terminal-{}", predicted_terminal_id.0)
2437        } else {
2438            let nanos = std::time::SystemTime::now()
2439                .duration_since(std::time::UNIX_EPOCH)
2440                .map(|d| d.as_nanos())
2441                .unwrap_or(0);
2442            format!("fresh-terminal-eph-{}-{}", predicted_terminal_id.0, nanos)
2443        };
2444        let log_path = terminal_root.join(format!("{}.log", name_stem));
2445        let backing_path = terminal_root.join(format!("{}.txt", name_stem));
2446        self.terminal_backing_files
2447            .insert(predicted_terminal_id, backing_path);
2448        let backing_path_for_spawn = self
2449            .terminal_backing_files
2450            .get(&predicted_terminal_id)
2451            .cloned();
2452
2453        match self.terminal_manager.spawn(
2454            cols,
2455            rows,
2456            Some(working_dir),
2457            Some(log_path.clone()),
2458            backing_path_for_spawn,
2459            self.resolved_terminal_wrapper(),
2460        ) {
2461            Ok(terminal_id) => {
2462                // Track log file path
2463                self.terminal_log_files
2464                    .insert(terminal_id, log_path.clone());
2465                // Fix up backing path if the predicted ID didn't match
2466                // the one the terminal manager handed out. Persistent
2467                // terminals re-derive the stable `fresh-terminal-N.txt`
2468                // name so the workspace restore path can find them;
2469                // ephemeral terminals just keep the already-spawned
2470                // file (it has a nanos-unique name either way) and
2471                // rebind the HashMap key to the real ID.
2472                if terminal_id != predicted_terminal_id {
2473                    let existing = self.terminal_backing_files.remove(&predicted_terminal_id);
2474                    let fixed_backing = if persistent {
2475                        terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
2476                    } else {
2477                        existing.unwrap_or_else(|| terminal_root.join(format!("{}.txt", name_stem)))
2478                    };
2479                    self.terminal_backing_files
2480                        .insert(terminal_id, fixed_backing);
2481                }
2482                if !persistent {
2483                    self.ephemeral_terminals.insert(terminal_id);
2484                }
2485
2486                // Pick buffer-attachment strategy based on whether the
2487                // plugin asked for its own split:
2488                //
2489                // - direction = Some: use `_detached` so the buffer
2490                //   isn't also added as a tab to the user's active
2491                //   split. The new split below owns it exclusively,
2492                //   so when the user closes that split the terminal
2493                //   disappears entirely instead of leaving a ghost
2494                //   tab behind in the main split.
2495                // - direction = None: use `_attached` — the plugin
2496                //   is intentionally placing the terminal as a new
2497                //   tab in the active split, which is the whole
2498                //   point of the no-split branch.
2499                let active_split = self.split_manager.active_split();
2500                let buffer_id = if direction.is_some() {
2501                    self.create_terminal_buffer_detached(terminal_id)
2502                } else {
2503                    self.create_terminal_buffer_attached(terminal_id, active_split)
2504                };
2505
2506                let created_split_id = if let Some(dir_str) = direction.as_deref() {
2507                    let split_dir = match dir_str {
2508                        "horizontal" => crate::model::event::SplitDirection::Horizontal,
2509                        _ => crate::model::event::SplitDirection::Vertical,
2510                    };
2511
2512                    let split_ratio = ratio.unwrap_or(0.5);
2513                    match self
2514                        .split_manager
2515                        .split_active(split_dir, buffer_id, split_ratio)
2516                    {
2517                        Ok(new_split_id) => {
2518                            let mut view_state = SplitViewState::with_buffer(
2519                                self.terminal_width,
2520                                self.terminal_height,
2521                                buffer_id,
2522                            );
2523                            view_state.apply_config_defaults(
2524                                self.config.editor.line_numbers,
2525                                self.config.editor.highlight_current_line,
2526                                false,
2527                                false,
2528                                None,
2529                                self.config.editor.rulers.clone(),
2530                            );
2531                            // Terminal output is ANSI-sequenced and
2532                            // assumes a fixed column count; wrapping
2533                            // would mangle cursor positioning.
2534                            view_state.viewport.line_wrap_enabled = false;
2535                            self.split_view_states.insert(new_split_id, view_state);
2536
2537                            if focus.unwrap_or(true) {
2538                                self.split_manager.set_active_split(new_split_id);
2539                            }
2540
2541                            tracing::info!(
2542                                "Created {:?} split for terminal {:?} with buffer {:?}",
2543                                split_dir,
2544                                terminal_id,
2545                                buffer_id
2546                            );
2547                            Some(new_split_id)
2548                        }
2549                        Err(e) => {
2550                            tracing::error!(
2551                                "Failed to create split for terminal: {}; \
2552                                         falling back to active split",
2553                                e
2554                            );
2555                            // The buffer was created detached. Split
2556                            // creation failed, so attach it to the
2557                            // active split as a graceful fallback
2558                            // rather than leaving an orphan buffer.
2559                            if let Some(view_state) = self.split_view_states.get_mut(&active_split)
2560                            {
2561                                view_state.add_buffer(buffer_id);
2562                                view_state.viewport.line_wrap_enabled = false;
2563                            }
2564                            self.set_active_buffer(buffer_id);
2565                            None
2566                        }
2567                    }
2568                } else {
2569                    // No split — just switch to the terminal buffer in the active split
2570                    self.set_active_buffer(buffer_id);
2571                    None
2572                };
2573
2574                // Resize terminal to match actual split content area
2575                self.resize_visible_terminals();
2576
2577                // Resolve the callback with TerminalResult
2578                let result = fresh_core::api::TerminalResult {
2579                    buffer_id: buffer_id.0 as u64,
2580                    terminal_id: terminal_id.0 as u64,
2581                    split_id: created_split_id.map(|s| s.0 .0 as u64),
2582                };
2583                self.plugin_manager.resolve_callback(
2584                    fresh_core::api::JsCallbackId::from(request_id),
2585                    serde_json::to_string(&result).unwrap_or_default(),
2586                );
2587
2588                tracing::info!(
2589                    "Plugin created terminal {:?} with buffer {:?}",
2590                    terminal_id,
2591                    buffer_id
2592                );
2593            }
2594            Err(e) => {
2595                tracing::error!("Failed to create terminal for plugin: {}", e);
2596                self.plugin_manager.reject_callback(
2597                    fresh_core::api::JsCallbackId::from(request_id),
2598                    format!("Failed to create terminal: {}", e),
2599                );
2600            }
2601        }
2602    }
2603    // ==================== Extracted handlers for previously inline match arms ====================
2604
2605    fn handle_get_split_by_label(&mut self, label: String, request_id: u64) {
2606        let split_id = self.split_manager.find_split_by_label(&label);
2607        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2608        let json =
2609            serde_json::to_string(&split_id.map(|s| s.0 .0)).unwrap_or_else(|_| "null".to_string());
2610        self.plugin_manager.resolve_callback(callback_id, json);
2611    }
2612
2613    fn handle_set_buffer_show_cursors(&mut self, buffer_id: BufferId, show: bool) {
2614        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2615            state.show_cursors = show;
2616        } else {
2617            tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
2618        }
2619    }
2620
2621    fn handle_override_theme_colors(
2622        &mut self,
2623        overrides: std::collections::HashMap<String, [u8; 3]>,
2624    ) {
2625        let pairs = overrides
2626            .into_iter()
2627            .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
2628        let applied = self.theme.override_colors(pairs);
2629        if applied > 0 {
2630            // Diagnostics / semantic overlays bake RGB at creation time — rebuild
2631            // them so the override is visible everywhere on the next frame.
2632            self.reapply_all_overlays();
2633        }
2634    }
2635
2636    fn handle_await_next_key(&mut self, callback_id: fresh_core::api::JsCallbackId) {
2637        // If keys arrived during a key-capture window while no callback was
2638        // pending, drain the front-most buffered key and resolve immediately.
2639        // Otherwise enqueue the callback for the next live keypress.
2640        if let Some(payload) = self.pending_key_capture_buffer.pop_front() {
2641            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
2642            self.plugin_manager.resolve_callback(callback_id, json);
2643        } else {
2644            self.pending_next_key_callbacks.push_back(callback_id);
2645        }
2646    }
2647
2648    fn handle_spawn_process(
2649        &mut self,
2650        command: String,
2651        args: Vec<String>,
2652        cwd: Option<String>,
2653        callback_id: fresh_core::api::JsCallbackId,
2654    ) {
2655        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2656            let effective_cwd = cwd.or_else(|| {
2657                std::env::current_dir()
2658                    .map(|p| p.to_string_lossy().to_string())
2659                    .ok()
2660            });
2661            let sender = bridge.sender();
2662            let spawner = self.authority.process_spawner.clone();
2663            runtime.spawn(async move {
2664                #[allow(clippy::let_underscore_must_use)]
2665                match spawner.spawn(command, args, effective_cwd).await {
2666                    Ok(result) => {
2667                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
2668                            process_id: callback_id.as_u64(),
2669                            stdout: result.stdout,
2670                            stderr: result.stderr,
2671                            exit_code: result.exit_code,
2672                        });
2673                    }
2674                    Err(e) => {
2675                        let _ = sender.send(AsyncMessage::PluginProcessOutput {
2676                            process_id: callback_id.as_u64(),
2677                            stdout: String::new(),
2678                            stderr: e.to_string(),
2679                            exit_code: -1,
2680                        });
2681                    }
2682                }
2683            });
2684        } else {
2685            self.plugin_manager
2686                .reject_callback(callback_id, "Async runtime not available".to_string());
2687        }
2688    }
2689
2690    fn handle_kill_host_process(&mut self, process_id: u64) {
2691        // Removing from the map gives us the oneshot sender. Firing it signals
2692        // the spawn task to start_kill() the child and reap. Unknown IDs are
2693        // intentionally silent — the process may have already exited.
2694        if let Some(tx) = self.host_process_handles.remove(&process_id) {
2695            #[allow(clippy::let_underscore_must_use)]
2696            let _ = tx.send(());
2697            tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
2698        } else {
2699            tracing::debug!(
2700                "KillHostProcess: unknown process_id={} (already exited?)",
2701                process_id
2702            );
2703        }
2704    }
2705
2706    fn handle_set_authority(&mut self, payload: serde_json::Value) {
2707        // Payload is opaque at the fresh-core layer; the concrete schema lives
2708        // in services::authority::AuthorityPayload so core stays ignorant of backend kinds.
2709        match serde_json::from_value::<crate::services::authority::AuthorityPayload>(payload) {
2710            Ok(parsed) => {
2711                match crate::services::authority::Authority::from_plugin_payload(parsed) {
2712                    Ok(auth) => {
2713                        tracing::info!("Plugin installed new authority");
2714                        self.install_authority(auth);
2715                    }
2716                    Err(e) => {
2717                        tracing::warn!("setAuthority: invalid payload: {}", e);
2718                        self.set_status_message(format!("setAuthority rejected: {}", e));
2719                    }
2720                }
2721            }
2722            Err(e) => {
2723                tracing::warn!("setAuthority: failed to parse payload: {}", e);
2724                self.set_status_message(format!("setAuthority rejected: {}", e));
2725            }
2726        }
2727    }
2728
2729    fn handle_set_remote_indicator_state(&mut self, state: serde_json::Value) {
2730        // Opaque JSON at the fresh-core boundary; the concrete schema
2731        // (RemoteIndicatorOverride) lives in the view crate.
2732        match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(state)
2733        {
2734            Ok(over) => {
2735                self.remote_indicator_override = Some(over);
2736            }
2737            Err(e) => {
2738                tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
2739                self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
2740            }
2741        }
2742    }
2743
2744    fn handle_spawn_process_wait(
2745        &mut self,
2746        process_id: u64,
2747        callback_id: fresh_core::api::JsCallbackId,
2748    ) {
2749        tracing::warn!(
2750            "SpawnProcessWait not fully implemented - process_id={}",
2751            process_id
2752        );
2753        self.plugin_manager.reject_callback(
2754            callback_id,
2755            format!(
2756                "SpawnProcessWait not yet fully implemented for process_id={}",
2757                process_id
2758            ),
2759        );
2760    }
2761
2762    fn handle_delay(&mut self, callback_id: fresh_core::api::JsCallbackId, duration_ms: u64) {
2763        if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2764            let sender = bridge.sender();
2765            let callback_id_u64 = callback_id.as_u64();
2766            runtime.spawn(async move {
2767                tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
2768                #[allow(clippy::let_underscore_must_use)]
2769                let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2770                    fresh_core::api::PluginAsyncMessage::DelayComplete {
2771                        callback_id: callback_id_u64,
2772                    },
2773                ));
2774            });
2775        } else {
2776            std::thread::sleep(std::time::Duration::from_millis(duration_ms));
2777            self.plugin_manager
2778                .resolve_callback(callback_id, "null".to_string());
2779        }
2780    }
2781
2782    fn handle_kill_background_process(&mut self, process_id: u64) {
2783        if let Some(handle) = self.background_process_handles.remove(&process_id) {
2784            handle.abort();
2785            tracing::debug!("Killed background process {}", process_id);
2786        }
2787    }
2788
2789    fn handle_create_virtual_buffer(&mut self, name: String, mode: String, read_only: bool) {
2790        let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
2791        tracing::info!(
2792            "Created virtual buffer '{}' with mode '{}' (id={:?})",
2793            name,
2794            mode,
2795            buffer_id
2796        );
2797        // TODO: Return buffer_id to plugin via callback or hook
2798    }
2799
2800    fn handle_set_virtual_buffer_content(
2801        &mut self,
2802        buffer_id: BufferId,
2803        entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2804    ) {
2805        match self.set_virtual_buffer_content(buffer_id, entries) {
2806            Ok(()) => {
2807                tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
2808            }
2809            Err(e) => {
2810                tracing::error!("Failed to set virtual buffer content: {}", e);
2811            }
2812        }
2813    }
2814
2815    fn handle_get_text_properties_at_cursor(&self, buffer_id: BufferId) {
2816        if let Some(state) = self.buffers.get(&buffer_id) {
2817            let cursor_pos = self
2818                .split_view_states
2819                .values()
2820                .find_map(|vs| vs.buffer_state(buffer_id))
2821                .map(|bs| bs.cursors.primary().position)
2822                .unwrap_or(0);
2823            let properties = state.text_properties.get_at(cursor_pos);
2824            tracing::debug!(
2825                "Text properties at cursor in {:?}: {} properties found",
2826                buffer_id,
2827                properties.len()
2828            );
2829            // TODO: Fire hook with properties data for plugins to consume
2830        }
2831    }
2832
2833    fn handle_set_context(&mut self, name: String, active: bool) {
2834        if active {
2835            self.active_custom_contexts.insert(name.clone());
2836            tracing::debug!("Set custom context: {}", name);
2837        } else {
2838            self.active_custom_contexts.remove(&name);
2839            tracing::debug!("Unset custom context: {}", name);
2840        }
2841    }
2842
2843    fn handle_disable_lsp_for_language(&mut self, language: String) {
2844        tracing::info!("Disabling LSP for language: {}", language);
2845        if let Some(ref mut lsp) = self.lsp {
2846            lsp.shutdown_server(&language);
2847            tracing::info!("Stopped LSP server for {}", language);
2848        }
2849        if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
2850            for c in lsp_configs.as_mut_slice() {
2851                c.enabled = false;
2852                c.auto_start = false;
2853            }
2854            tracing::info!("Disabled LSP config for {}", language);
2855        }
2856        if let Err(e) = self.save_config() {
2857            tracing::error!("Failed to save config: {}", e);
2858            self.status_message = Some(format!(
2859                "LSP disabled for {} (config save failed)",
2860                language
2861            ));
2862        } else {
2863            self.status_message = Some(format!("LSP disabled for {}", language));
2864        }
2865        self.warning_domains.lsp.clear();
2866    }
2867
2868    fn handle_restart_lsp_for_language(&mut self, language: String) {
2869        tracing::info!("Plugin restarting LSP for language: {}", language);
2870        let file_path = self
2871            .buffer_metadata
2872            .get(&self.active_buffer())
2873            .and_then(|meta| meta.file_path().cloned());
2874        let success = if let Some(ref mut lsp) = self.lsp {
2875            let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
2876            self.status_message = Some(msg);
2877            ok
2878        } else {
2879            self.status_message = Some("No LSP manager available".to_string());
2880            false
2881        };
2882        if success {
2883            self.reopen_buffers_for_language(&language);
2884        }
2885    }
2886
2887    fn handle_set_lsp_root_uri(&mut self, language: String, uri: String) {
2888        tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
2889        match uri.parse::<lsp_types::Uri>() {
2890            Ok(parsed_uri) => {
2891                if let Some(ref mut lsp) = self.lsp {
2892                    let restarted = lsp.set_language_root_uri(&language, parsed_uri);
2893                    if restarted {
2894                        self.status_message = Some(format!(
2895                            "LSP root updated for {} (restarting server)",
2896                            language
2897                        ));
2898                    } else {
2899                        self.status_message = Some(format!("LSP root set for {}", language));
2900                    }
2901                }
2902            }
2903            Err(e) => {
2904                tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
2905                self.status_message = Some(format!("Invalid LSP root URI: {}", e));
2906            }
2907        }
2908    }
2909
2910    fn handle_create_scroll_sync_group(
2911        &mut self,
2912        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
2913        left_split: SplitId,
2914        right_split: SplitId,
2915    ) {
2916        let success =
2917            self.scroll_sync_manager
2918                .create_group_with_id(group_id, left_split, right_split);
2919        if success {
2920            tracing::debug!(
2921                "Created scroll sync group {} for splits {:?} and {:?}",
2922                group_id,
2923                left_split,
2924                right_split
2925            );
2926        } else {
2927            tracing::warn!(
2928                "Failed to create scroll sync group {} (ID already exists)",
2929                group_id
2930            );
2931        }
2932    }
2933
2934    fn handle_set_scroll_sync_anchors(
2935        &mut self,
2936        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
2937        anchors: Vec<(usize, usize)>,
2938    ) {
2939        use crate::view::scroll_sync::SyncAnchor;
2940        let anchor_count = anchors.len();
2941        let sync_anchors: Vec<SyncAnchor> = anchors
2942            .into_iter()
2943            .map(|(left_line, right_line)| SyncAnchor {
2944                left_line,
2945                right_line,
2946            })
2947            .collect();
2948        self.scroll_sync_manager.set_anchors(group_id, sync_anchors);
2949        tracing::debug!(
2950            "Set {} anchors for scroll sync group {}",
2951            anchor_count,
2952            group_id
2953        );
2954    }
2955
2956    fn handle_remove_scroll_sync_group(
2957        &mut self,
2958        group_id: crate::view::scroll_sync::ScrollSyncGroupId,
2959    ) {
2960        if self.scroll_sync_manager.remove_group(group_id) {
2961            tracing::debug!("Removed scroll sync group {}", group_id);
2962        } else {
2963            tracing::warn!("Scroll sync group {} not found", group_id);
2964        }
2965    }
2966
2967    fn handle_create_buffer_group(
2968        &mut self,
2969        name: String,
2970        mode: String,
2971        layout_json: String,
2972        request_id: Option<u64>,
2973    ) {
2974        match self.create_buffer_group(name, mode, layout_json) {
2975            Ok(result) => {
2976                if let Some(req_id) = request_id {
2977                    let json = serde_json::to_string(&result).unwrap_or_default();
2978                    self.plugin_manager
2979                        .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
2980                }
2981            }
2982            Err(e) => {
2983                tracing::error!("Failed to create buffer group: {}", e);
2984            }
2985        }
2986    }
2987
2988    fn handle_send_terminal_input(
2989        &mut self,
2990        terminal_id: crate::services::terminal::TerminalId,
2991        data: String,
2992    ) {
2993        if let Some(handle) = self.terminal_manager.get(terminal_id) {
2994            handle.write(data.as_bytes());
2995            tracing::trace!(
2996                "Plugin sent {} bytes to terminal {:?}",
2997                data.len(),
2998                terminal_id
2999            );
3000        } else {
3001            tracing::warn!(
3002                "Plugin tried to send input to non-existent terminal {:?}",
3003                terminal_id
3004            );
3005        }
3006    }
3007
3008    fn handle_close_terminal(&mut self, terminal_id: crate::services::terminal::TerminalId) {
3009        let buffer_to_close = self
3010            .terminal_buffers
3011            .iter()
3012            .find(|(_, &tid)| tid == terminal_id)
3013            .map(|(&bid, _)| bid);
3014        if let Some(buffer_id) = buffer_to_close {
3015            if let Err(e) = self.close_buffer(buffer_id) {
3016                tracing::warn!("Failed to close terminal buffer: {}", e);
3017            }
3018            tracing::info!("Plugin closed terminal {:?}", terminal_id);
3019        } else {
3020            self.terminal_manager.close(terminal_id);
3021            tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
3022        }
3023    }
3024}
3025
3026#[cfg(test)]
3027mod tests {
3028    //! Focused tests for the SpawnHostProcess kill mechanism.
3029    //!
3030    //! These don't exercise the full `handle_plugin_command` dispatcher
3031    //! (which would require scaffolding an Editor with a real tokio
3032    //! runtime and async_bridge); they replicate the inner
3033    //! `tokio::select!` pattern directly on a real subprocess. A
3034    //! regression in the select arms or in the kill-then-wait
3035    //! sequencing would reproduce here.
3036    //!
3037    //! The dispatcher-level integration coverage comes from the e2e
3038    //! attach-cancel test in `tests/e2e/` — this unit test is the
3039    //! lower-level pin.
3040    use tokio::io::{AsyncReadExt, BufReader};
3041    use tokio::process::Command as TokioCommand;
3042    use tokio::time::{timeout, Duration};
3043
3044    /// A long-sleep child that runs `tokio::select! { wait | kill_rx }`
3045    /// terminates when the kill channel fires, and the terminal exit
3046    /// code reflects signal termination (non-zero / None).
3047    ///
3048    /// Spawns `sleep` directly rather than through `sh -c` so SIGKILL
3049    /// reaches the process whose pipe our reader futures hold —
3050    /// `sh -c sleep` leaks the sleep child on SIGKILL (Q-C2), the
3051    /// pipe stays open, and the reader future hangs. That's a
3052    /// deliberate known limitation of start_kill; this test
3053    /// exercises the clean path.
3054    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3055    async fn kill_via_oneshot_terminates_long_running_child() {
3056        let mut cmd = TokioCommand::new("sleep");
3057        cmd.args(["30"]);
3058        cmd.stdout(std::process::Stdio::piped());
3059        cmd.stderr(std::process::Stdio::piped());
3060
3061        let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
3062        let pid = child.id().expect("child has a pid");
3063
3064        let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
3065        let stdout_pipe = child.stdout.take();
3066        let stderr_pipe = child.stderr.take();
3067
3068        let stdout_fut = async {
3069            let mut buf = String::new();
3070            if let Some(s) = stdout_pipe {
3071                #[allow(clippy::let_underscore_must_use)]
3072                let _ = BufReader::new(s).read_to_string(&mut buf).await;
3073            }
3074            buf
3075        };
3076        let stderr_fut = async {
3077            let mut buf = String::new();
3078            if let Some(s) = stderr_pipe {
3079                #[allow(clippy::let_underscore_must_use)]
3080                let _ = BufReader::new(s).read_to_string(&mut buf).await;
3081            }
3082            buf
3083        };
3084        let wait_fut = async {
3085            tokio::select! {
3086                status = child.wait() => {
3087                    status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
3088                }
3089                _ = &mut kill_rx => {
3090                    #[allow(clippy::let_underscore_must_use)]
3091                    let _ = child.start_kill();
3092                    child
3093                        .wait()
3094                        .await
3095                        .map(|s| s.code().unwrap_or(-1))
3096                        .unwrap_or(-1)
3097                }
3098            }
3099        };
3100
3101        // Give the shell a moment to install itself — firing kill
3102        // against an not-yet-existent child is still valid (SIGKILL
3103        // to a zombie is a no-op) but we want to actually exercise
3104        // the running-child path.
3105        tokio::time::sleep(Duration::from_millis(50)).await;
3106        kill_tx.send(()).expect("kill channel send");
3107
3108        let result = timeout(Duration::from_secs(5), async {
3109            tokio::join!(stdout_fut, stderr_fut, wait_fut)
3110        })
3111        .await;
3112
3113        let (_stdout, _stderr, exit_code) = result.expect(
3114            "kill path must resolve within 5s — if this times out the \
3115             select! arm order or kill-then-wait logic is broken",
3116        );
3117        // The cross-platform invariant is "the child did not complete
3118        // its 30s sleep" — i.e. the exit code is non-success. Platform
3119        // specifics:
3120        //   - Unix: `start_kill()` sends SIGKILL; `ExitStatus::code()`
3121        //     returns None for signal-terminated processes, which our
3122        //     dispatcher maps to -1 via `.unwrap_or(-1)`.
3123        //   - Windows: `start_kill()` calls `TerminateProcess(..., 1)`;
3124        //     `code()` returns `Some(1)`, mapped to 1 by the same
3125        //     `.unwrap_or(-1)`.
3126        // A successful 30s sleep would yield 0 — that's the
3127        // regression case we're guarding against.
3128        assert_ne!(
3129            exit_code, 0,
3130            "killed child must exit non-success (got 0 — did the \
3131             kill arm fire too late, or did sleep somehow complete?)"
3132        );
3133
3134        // Sanity: on Unix the child must be gone. `kill -0 <pid>`
3135        // returns 0 iff the process still exists; we expect non-zero
3136        // (No such process) after wait(). This catches a zombie /
3137        // leaked child that would indicate we skipped the wait() on
3138        // the kill path. Skipped on Windows — `kill` isn't available
3139        // and `tasklist` output parsing is more noise than signal
3140        // for this one-shot check; the wait() having returned is
3141        // already evidence of reap there.
3142        #[cfg(unix)]
3143        {
3144            let still_alive = std::process::Command::new("kill")
3145                .args(["-0", &pid.to_string()])
3146                .status()
3147                .map(|s| s.success())
3148                .unwrap_or(false);
3149            assert!(
3150                !still_alive,
3151                "process {pid} must be reaped after wait() — a still-\
3152                 alive check means the kill path leaked the child"
3153            );
3154        }
3155        #[cfg(not(unix))]
3156        {
3157            // Touch `pid` so the unused-variable lint doesn't fire on
3158            // non-Unix builds.
3159            let _ = pid;
3160        }
3161    }
3162}