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