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                let buffer_info = BufferInfo {
96                    id: *buffer_id,
97                    path: state.buffer.file_path().map(|p| p.to_path_buf()),
98                    modified: state.buffer.is_modified(),
99                    length: state.buffer.len(),
100                    is_virtual,
101                    view_mode: view_mode.to_string(),
102                    is_composing_in_any_split,
103                    compose_width,
104                    language: state.language.clone(),
105                    is_preview,
106                };
107                snapshot.buffers.insert(*buffer_id, buffer_info);
108
109                let diff = {
110                    let diff = state.buffer.diff_since_saved();
111                    BufferSavedDiff {
112                        equal: diff.equal,
113                        byte_ranges: diff.byte_ranges.clone(),
114                    }
115                };
116                snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
117
118                // Regular buffers live in exactly one split's keyed_states.
119                // Panel (hidden) buffers natively live inside a group's inner
120                // split — but the close-buffer path can leave a *shadow*
121                // entry in the group's host split (from `switch_buffer`'s
122                // auto-insert, kept to preserve the
123                // `active_buffer ∈ keyed_states` invariant). For hidden
124                // buffers we therefore skip group-host splits and pick the
125                // inner split, which is the authoritative home.
126                let is_hidden = self
127                    .buffer_metadata
128                    .get(buffer_id)
129                    .is_some_and(|m| m.hidden_from_tabs);
130                let source_split = self.split_view_states.iter().find(|(split_id, vs)| {
131                    vs.keyed_states.contains_key(buffer_id)
132                        && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
133                });
134                let cursor_pos = source_split
135                    .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
136                    .map(|bs| bs.cursors.primary().position)
137                    .unwrap_or(0);
138                tracing::trace!(
139                    "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
140                    buffer_id,
141                    cursor_pos,
142                    source_split.map(|(id, _)| *id),
143                );
144                snapshot
145                    .buffer_cursor_positions
146                    .insert(*buffer_id, cursor_pos);
147
148                // Store text properties if this buffer has any
149                if !state.text_properties.is_empty() {
150                    snapshot
151                        .buffer_text_properties
152                        .insert(*buffer_id, state.text_properties.all().to_vec());
153                }
154            }
155
156            // Update cursor information for active buffer
157            if let Some(active_vs) = self
158                .split_view_states
159                .get(&self.split_manager.active_split())
160            {
161                // Primary cursor (from SplitViewState)
162                let active_cursors = &active_vs.cursors;
163                let primary = active_cursors.primary();
164                let primary_position = primary.position;
165                let primary_selection = primary.selection_range();
166
167                snapshot.primary_cursor = Some(CursorInfo {
168                    position: primary_position,
169                    selection: primary_selection.clone(),
170                });
171
172                // All cursors
173                snapshot.all_cursors = active_cursors
174                    .iter()
175                    .map(|(_, cursor)| CursorInfo {
176                        position: cursor.position,
177                        selection: cursor.selection_range(),
178                    })
179                    .collect();
180
181                // Selected text from primary cursor (for clipboard plugin)
182                if let Some(range) = primary_selection {
183                    if let Some(active_state) = self.buffers.get_mut(&self.active_buffer()) {
184                        snapshot.selected_text =
185                            Some(active_state.get_text_range(range.start, range.end));
186                    }
187                }
188
189                // Viewport - get from SplitViewState (the authoritative source)
190                let top_line = self.buffers.get(&self.active_buffer()).and_then(|state| {
191                    if state.buffer.line_count().is_some() {
192                        Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
193                    } else {
194                        None
195                    }
196                });
197                snapshot.viewport = Some(ViewportInfo {
198                    top_byte: active_vs.viewport.top_byte,
199                    top_line,
200                    left_column: active_vs.viewport.left_column,
201                    width: active_vs.viewport.width,
202                    height: active_vs.viewport.height,
203                });
204            } else {
205                snapshot.primary_cursor = None;
206                snapshot.all_cursors.clear();
207                snapshot.viewport = None;
208                snapshot.selected_text = None;
209            }
210
211            // Update clipboard (provide internal clipboard content to plugins)
212            snapshot.clipboard = self.clipboard.get_internal().to_string();
213
214            // Update working directory (for spawning processes in correct directory)
215            snapshot.working_dir = self.working_dir.clone();
216
217            // Update LSP diagnostics: Arc refcount bump; no clone.
218            snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
219
220            // Update LSP folding ranges: Arc refcount bump; no clone.
221            snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
222
223            // Update config. Reserialize only when the underlying
224            // `Arc<Config>` pointer has actually moved since the last
225            // refresh — `Arc::ptr_eq` vs `config_snapshot_anchor` is a
226            // sound cache key because the anchor keeps `self.config`'s
227            // strong count at ≥ 2, forcing every `Arc::make_mut` on the
228            // editor side to CoW into a new allocation. On idle (no
229            // config mutation), this branch is skipped entirely and the
230            // snapshot update is a refcount bump.
231            if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
232                let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
233                self.config_cached_json = Arc::new(json);
234                self.config_snapshot_anchor = Arc::clone(&self.config);
235            }
236            snapshot.config = Arc::clone(&self.config_cached_json);
237
238            // Update user config (cached raw file contents, not merged with defaults).
239            // This allows plugins to distinguish between user-set and default values.
240            // Arc refcount bump; no clone.
241            snapshot.user_config = Arc::clone(&self.user_config_raw);
242
243            // Update editor mode (for vi mode and other modal editing)
244            snapshot.editor_mode = self.editor_mode.clone();
245
246            // Update plugin global states from Rust-side store.
247            // Merge using or_insert to preserve JS-side write-through entries.
248            for (plugin_name, state_map) in &self.plugin_global_state {
249                let entry = snapshot
250                    .plugin_global_states
251                    .entry(plugin_name.clone())
252                    .or_default();
253                for (key, value) in state_map {
254                    entry.entry(key.clone()).or_insert_with(|| value.clone());
255                }
256            }
257
258            // Update plugin view states from active split's BufferViewState.plugin_state.
259            // If the active split changed, fully repopulate. Otherwise, merge using
260            // or_insert to preserve JS-side write-through entries that haven't
261            // round-tripped through the command channel yet.
262            let active_split_id = self.split_manager.active_split().0 .0;
263            let split_changed = snapshot.plugin_view_states_split != active_split_id;
264            if split_changed {
265                snapshot.plugin_view_states.clear();
266                snapshot.plugin_view_states_split = active_split_id;
267            }
268
269            // Clean up entries for buffers that are no longer open
270            {
271                let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
272                snapshot
273                    .plugin_view_states
274                    .retain(|bid, _| open_bids.contains(bid));
275            }
276
277            // Merge from Rust-side plugin_state (source of truth for persisted state)
278            if let Some(active_vs) = self
279                .split_view_states
280                .get(&self.split_manager.active_split())
281            {
282                for (buffer_id, buf_state) in &active_vs.keyed_states {
283                    if !buf_state.plugin_state.is_empty() {
284                        let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
285                        for (key, value) in &buf_state.plugin_state {
286                            // Use or_insert to preserve JS write-through values
287                            entry.entry(key.clone()).or_insert_with(|| value.clone());
288                        }
289                    }
290                }
291            }
292        }
293    }
294
295    /// Handle a plugin command - dispatches to specialized handlers in plugin_commands module
296    pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
297        match command {
298            // ==================== Text Editing Commands ====================
299            PluginCommand::InsertText {
300                buffer_id,
301                position,
302                text,
303            } => {
304                self.handle_insert_text(buffer_id, position, text);
305            }
306            PluginCommand::DeleteRange { buffer_id, range } => {
307                self.handle_delete_range(buffer_id, range);
308            }
309            PluginCommand::InsertAtCursor { text } => {
310                self.handle_insert_at_cursor(text);
311            }
312            PluginCommand::DeleteSelection => {
313                self.handle_delete_selection();
314            }
315
316            // ==================== Overlay Commands ====================
317            PluginCommand::AddOverlay {
318                buffer_id,
319                namespace,
320                range,
321                options,
322            } => {
323                self.handle_add_overlay(buffer_id, namespace, range, options);
324            }
325            PluginCommand::RemoveOverlay { buffer_id, handle } => {
326                self.handle_remove_overlay(buffer_id, handle);
327            }
328            PluginCommand::ClearAllOverlays { buffer_id } => {
329                self.handle_clear_all_overlays(buffer_id);
330            }
331            PluginCommand::ClearNamespace {
332                buffer_id,
333                namespace,
334            } => {
335                self.handle_clear_namespace(buffer_id, namespace);
336            }
337            PluginCommand::ClearOverlaysInRange {
338                buffer_id,
339                start,
340                end,
341            } => {
342                self.handle_clear_overlays_in_range(buffer_id, start, end);
343            }
344
345            // ==================== Virtual Text Commands ====================
346            PluginCommand::AddVirtualText {
347                buffer_id,
348                virtual_text_id,
349                position,
350                text,
351                color,
352                use_bg,
353                before,
354            } => {
355                self.handle_add_virtual_text(
356                    buffer_id,
357                    virtual_text_id,
358                    position,
359                    text,
360                    color,
361                    use_bg,
362                    before,
363                );
364            }
365            PluginCommand::RemoveVirtualText {
366                buffer_id,
367                virtual_text_id,
368            } => {
369                self.handle_remove_virtual_text(buffer_id, virtual_text_id);
370            }
371            PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
372                self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
373            }
374            PluginCommand::ClearVirtualTexts { buffer_id } => {
375                self.handle_clear_virtual_texts(buffer_id);
376            }
377            PluginCommand::AddVirtualLine {
378                buffer_id,
379                position,
380                text,
381                fg_color,
382                bg_color,
383                above,
384                namespace,
385                priority,
386            } => {
387                self.handle_add_virtual_line(
388                    buffer_id, position, text, fg_color, bg_color, above, namespace, priority,
389                );
390            }
391            PluginCommand::ClearVirtualTextNamespace {
392                buffer_id,
393                namespace,
394            } => {
395                self.handle_clear_virtual_text_namespace(buffer_id, namespace);
396            }
397
398            // ==================== Conceal Commands ====================
399            PluginCommand::AddConceal {
400                buffer_id,
401                namespace,
402                start,
403                end,
404                replacement,
405            } => {
406                self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
407            }
408            PluginCommand::ClearConcealNamespace {
409                buffer_id,
410                namespace,
411            } => {
412                self.handle_clear_conceal_namespace(buffer_id, namespace);
413            }
414            PluginCommand::ClearConcealsInRange {
415                buffer_id,
416                start,
417                end,
418            } => {
419                self.handle_clear_conceals_in_range(buffer_id, start, end);
420            }
421
422            PluginCommand::AddFold {
423                buffer_id,
424                start,
425                end,
426                placeholder,
427            } => {
428                self.handle_add_fold(buffer_id, start, end, placeholder);
429            }
430            PluginCommand::ClearFolds { buffer_id } => {
431                self.handle_clear_folds(buffer_id);
432            }
433
434            // ==================== Soft Break Commands ====================
435            PluginCommand::AddSoftBreak {
436                buffer_id,
437                namespace,
438                position,
439                indent,
440            } => {
441                self.handle_add_soft_break(buffer_id, namespace, position, indent);
442            }
443            PluginCommand::ClearSoftBreakNamespace {
444                buffer_id,
445                namespace,
446            } => {
447                self.handle_clear_soft_break_namespace(buffer_id, namespace);
448            }
449            PluginCommand::ClearSoftBreaksInRange {
450                buffer_id,
451                start,
452                end,
453            } => {
454                self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
455            }
456
457            // ==================== Menu Commands ====================
458            PluginCommand::AddMenuItem {
459                menu_label,
460                item,
461                position,
462            } => {
463                self.handle_add_menu_item(menu_label, item, position);
464            }
465            PluginCommand::AddMenu { menu, position } => {
466                self.handle_add_menu(menu, position);
467            }
468            PluginCommand::RemoveMenuItem {
469                menu_label,
470                item_label,
471            } => {
472                self.handle_remove_menu_item(menu_label, item_label);
473            }
474            PluginCommand::RemoveMenu { menu_label } => {
475                self.handle_remove_menu(menu_label);
476            }
477
478            // ==================== Split Commands ====================
479            PluginCommand::FocusSplit { split_id } => {
480                self.handle_focus_split(split_id);
481            }
482            PluginCommand::SetSplitBuffer {
483                split_id,
484                buffer_id,
485            } => {
486                self.handle_set_split_buffer(split_id, buffer_id);
487            }
488            PluginCommand::SetSplitScroll { split_id, top_byte } => {
489                self.handle_set_split_scroll(split_id, top_byte);
490            }
491            PluginCommand::RequestHighlights {
492                buffer_id,
493                range,
494                request_id,
495            } => {
496                self.handle_request_highlights(buffer_id, range, request_id);
497            }
498            PluginCommand::CloseSplit { split_id } => {
499                self.handle_close_split(split_id);
500            }
501            PluginCommand::SetSplitRatio { split_id, ratio } => {
502                self.handle_set_split_ratio(split_id, ratio);
503            }
504            PluginCommand::SetSplitLabel { split_id, label } => {
505                self.split_manager.set_label(LeafId(split_id), label);
506            }
507            PluginCommand::ClearSplitLabel { split_id } => {
508                self.split_manager.clear_label(split_id);
509            }
510            PluginCommand::GetSplitByLabel { label, request_id } => {
511                let split_id = self.split_manager.find_split_by_label(&label);
512                let callback_id = fresh_core::api::JsCallbackId::from(request_id);
513                let json = serde_json::to_string(&split_id.map(|s| s.0 .0))
514                    .unwrap_or_else(|_| "null".to_string());
515                self.plugin_manager.resolve_callback(callback_id, json);
516            }
517            PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
518                self.handle_distribute_splits_evenly();
519            }
520            PluginCommand::SetBufferCursor {
521                buffer_id,
522                position,
523            } => {
524                self.handle_set_buffer_cursor(buffer_id, position);
525            }
526            PluginCommand::SetBufferShowCursors { buffer_id, show } => {
527                if let Some(state) = self.buffers.get_mut(&buffer_id) {
528                    state.show_cursors = show;
529                } else {
530                    tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
531                }
532            }
533
534            // ==================== View/Layout Commands ====================
535            PluginCommand::SetLayoutHints {
536                buffer_id,
537                split_id,
538                range: _,
539                hints,
540            } => {
541                self.handle_set_layout_hints(buffer_id, split_id, hints);
542            }
543            PluginCommand::SetLineNumbers { buffer_id, enabled } => {
544                self.handle_set_line_numbers(buffer_id, enabled);
545            }
546            PluginCommand::SetViewMode { buffer_id, mode } => {
547                self.handle_set_view_mode(buffer_id, &mode);
548            }
549            PluginCommand::SetLineWrap {
550                buffer_id,
551                split_id,
552                enabled,
553            } => {
554                self.handle_set_line_wrap(buffer_id, split_id, enabled);
555            }
556            PluginCommand::SubmitViewTransform {
557                buffer_id,
558                split_id,
559                payload,
560            } => {
561                self.handle_submit_view_transform(buffer_id, split_id, payload);
562            }
563            PluginCommand::ClearViewTransform {
564                buffer_id: _,
565                split_id,
566            } => {
567                self.handle_clear_view_transform(split_id);
568            }
569            PluginCommand::SetViewState {
570                buffer_id,
571                key,
572                value,
573            } => {
574                self.handle_set_view_state(buffer_id, key, value);
575            }
576            PluginCommand::SetGlobalState {
577                plugin_name,
578                key,
579                value,
580            } => {
581                self.handle_set_global_state(plugin_name, key, value);
582            }
583            PluginCommand::RefreshLines { buffer_id } => {
584                self.handle_refresh_lines(buffer_id);
585            }
586            PluginCommand::RefreshAllLines => {
587                self.handle_refresh_all_lines();
588            }
589            PluginCommand::HookCompleted { .. } => {
590                // Sentinel processed in render loop; no-op if encountered elsewhere.
591            }
592            PluginCommand::SetLineIndicator {
593                buffer_id,
594                line,
595                namespace,
596                symbol,
597                color,
598                priority,
599            } => {
600                self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
601            }
602            PluginCommand::SetLineIndicators {
603                buffer_id,
604                lines,
605                namespace,
606                symbol,
607                color,
608                priority,
609            } => {
610                self.handle_set_line_indicators(
611                    buffer_id, lines, namespace, symbol, color, priority,
612                );
613            }
614            PluginCommand::ClearLineIndicators {
615                buffer_id,
616                namespace,
617            } => {
618                self.handle_clear_line_indicators(buffer_id, namespace);
619            }
620            PluginCommand::SetFileExplorerDecorations {
621                namespace,
622                decorations,
623            } => {
624                self.handle_set_file_explorer_decorations(namespace, decorations);
625            }
626            PluginCommand::ClearFileExplorerDecorations { namespace } => {
627                self.handle_clear_file_explorer_decorations(&namespace);
628            }
629
630            // ==================== Status/Prompt Commands ====================
631            PluginCommand::SetStatus { message } => {
632                self.handle_set_status(message);
633            }
634            PluginCommand::ApplyTheme { theme_name } => {
635                self.apply_theme(&theme_name);
636            }
637            PluginCommand::ReloadConfig => {
638                self.reload_config();
639            }
640            PluginCommand::ReloadThemes { apply_theme } => {
641                self.reload_themes();
642                if let Some(theme_name) = apply_theme {
643                    self.apply_theme(&theme_name);
644                }
645            }
646            PluginCommand::RegisterGrammar {
647                language,
648                grammar_path,
649                extensions,
650            } => {
651                self.handle_register_grammar(language, grammar_path, extensions);
652            }
653            PluginCommand::RegisterLanguageConfig { language, config } => {
654                self.handle_register_language_config(language, config);
655            }
656            PluginCommand::RegisterLspServer { language, config } => {
657                self.handle_register_lsp_server(language, config);
658            }
659            PluginCommand::ReloadGrammars { callback_id } => {
660                self.handle_reload_grammars(callback_id);
661            }
662            PluginCommand::StartPrompt { label, prompt_type } => {
663                self.handle_start_prompt(label, prompt_type);
664            }
665            PluginCommand::StartPromptWithInitial {
666                label,
667                prompt_type,
668                initial_value,
669            } => {
670                self.handle_start_prompt_with_initial(label, prompt_type, initial_value);
671            }
672            PluginCommand::StartPromptAsync {
673                label,
674                initial_value,
675                callback_id,
676            } => {
677                self.handle_start_prompt_async(label, initial_value, callback_id);
678            }
679            PluginCommand::SetPromptSuggestions { suggestions } => {
680                self.handle_set_prompt_suggestions(suggestions);
681            }
682            PluginCommand::SetPromptInputSync { sync } => {
683                if let Some(prompt) = &mut self.prompt {
684                    prompt.sync_input_on_navigate = sync;
685                }
686            }
687
688            // ==================== Command/Mode Registration ====================
689            PluginCommand::RegisterCommand { command } => {
690                self.handle_register_command(command);
691            }
692            PluginCommand::UnregisterCommand { name } => {
693                self.handle_unregister_command(name);
694            }
695            PluginCommand::DefineMode {
696                name,
697                bindings,
698                read_only,
699                allow_text_input,
700                inherit_normal_bindings,
701                plugin_name,
702            } => {
703                self.handle_define_mode(
704                    name,
705                    bindings,
706                    read_only,
707                    allow_text_input,
708                    inherit_normal_bindings,
709                    plugin_name,
710                );
711            }
712
713            // ==================== File/Navigation Commands ====================
714            PluginCommand::OpenFileInBackground { path } => {
715                self.handle_open_file_in_background(path);
716            }
717            PluginCommand::OpenFileAtLocation { path, line, column } => {
718                return self.handle_open_file_at_location(path, line, column);
719            }
720            PluginCommand::OpenFileInSplit {
721                split_id,
722                path,
723                line,
724                column,
725            } => {
726                return self.handle_open_file_in_split(split_id, path, line, column);
727            }
728            PluginCommand::ShowBuffer { buffer_id } => {
729                self.handle_show_buffer(buffer_id);
730            }
731            PluginCommand::CloseBuffer { buffer_id } => {
732                self.handle_close_buffer(buffer_id);
733            }
734
735            // ==================== LSP Commands ====================
736            PluginCommand::SendLspRequest {
737                language,
738                method,
739                params,
740                request_id,
741            } => {
742                self.handle_send_lsp_request(language, method, params, request_id);
743            }
744
745            // ==================== Clipboard Commands ====================
746            PluginCommand::SetClipboard { text } => {
747                self.handle_set_clipboard(text);
748            }
749
750            // ==================== Async Plugin Commands ====================
751            PluginCommand::SpawnProcess {
752                command,
753                args,
754                cwd,
755                callback_id,
756            } => {
757                // Spawn process asynchronously using the process spawner
758                // (supports both local and remote execution)
759                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
760                    let effective_cwd = cwd.or_else(|| {
761                        std::env::current_dir()
762                            .map(|p| p.to_string_lossy().to_string())
763                            .ok()
764                    });
765                    let sender = bridge.sender();
766                    let spawner = self.process_spawner.clone();
767
768                    runtime.spawn(async move {
769                        // Receiver may be dropped if editor is shutting down
770                        #[allow(clippy::let_underscore_must_use)]
771                        match spawner.spawn(command, args, effective_cwd).await {
772                            Ok(result) => {
773                                let _ = sender.send(AsyncMessage::PluginProcessOutput {
774                                    process_id: callback_id.as_u64(),
775                                    stdout: result.stdout,
776                                    stderr: result.stderr,
777                                    exit_code: result.exit_code,
778                                });
779                            }
780                            Err(e) => {
781                                let _ = sender.send(AsyncMessage::PluginProcessOutput {
782                                    process_id: callback_id.as_u64(),
783                                    stdout: String::new(),
784                                    stderr: e.to_string(),
785                                    exit_code: -1,
786                                });
787                            }
788                        }
789                    });
790                } else {
791                    // No async runtime - reject the callback
792                    self.plugin_manager
793                        .reject_callback(callback_id, "Async runtime not available".to_string());
794                }
795            }
796
797            PluginCommand::SpawnProcessWait {
798                process_id,
799                callback_id,
800            } => {
801                // TODO: Implement proper process wait tracking
802                // For now, just reject with an error since there's no process tracking yet
803                tracing::warn!(
804                    "SpawnProcessWait not fully implemented - process_id={}",
805                    process_id
806                );
807                self.plugin_manager.reject_callback(
808                    callback_id,
809                    format!(
810                        "SpawnProcessWait not yet fully implemented for process_id={}",
811                        process_id
812                    ),
813                );
814            }
815
816            PluginCommand::Delay {
817                callback_id,
818                duration_ms,
819            } => {
820                // Spawn async delay via tokio
821                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
822                    let sender = bridge.sender();
823                    let callback_id_u64 = callback_id.as_u64();
824                    runtime.spawn(async move {
825                        tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
826                        // Receiver may have been dropped during shutdown.
827                        #[allow(clippy::let_underscore_must_use)]
828                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
829                            fresh_core::api::PluginAsyncMessage::DelayComplete {
830                                callback_id: callback_id_u64,
831                            },
832                        ));
833                    });
834                } else {
835                    // Fallback to blocking if no runtime available
836                    std::thread::sleep(std::time::Duration::from_millis(duration_ms));
837                    self.plugin_manager
838                        .resolve_callback(callback_id, "null".to_string());
839                }
840            }
841
842            PluginCommand::SpawnBackgroundProcess {
843                process_id,
844                command,
845                args,
846                cwd,
847                callback_id,
848            } => {
849                // Spawn background process with streaming output via tokio
850                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
851                    use tokio::io::{AsyncBufReadExt, BufReader};
852                    use tokio::process::Command as TokioCommand;
853
854                    let effective_cwd = cwd.unwrap_or_else(|| {
855                        std::env::current_dir()
856                            .map(|p| p.to_string_lossy().to_string())
857                            .unwrap_or_else(|_| ".".to_string())
858                    });
859
860                    let sender = bridge.sender();
861                    let sender_stdout = sender.clone();
862                    let sender_stderr = sender.clone();
863                    let callback_id_u64 = callback_id.as_u64();
864
865                    // Receiver may be dropped if editor is shutting down
866                    #[allow(clippy::let_underscore_must_use)]
867                    let handle = runtime.spawn(async move {
868                        let mut child = match TokioCommand::new(&command)
869                            .args(&args)
870                            .current_dir(&effective_cwd)
871                            .stdout(std::process::Stdio::piped())
872                            .stderr(std::process::Stdio::piped())
873                            .spawn()
874                        {
875                            Ok(child) => child,
876                            Err(e) => {
877                                let _ = sender.send(
878                                    crate::services::async_bridge::AsyncMessage::Plugin(
879                                        fresh_core::api::PluginAsyncMessage::ProcessExit {
880                                            process_id,
881                                            callback_id: callback_id_u64,
882                                            exit_code: -1,
883                                        },
884                                    ),
885                                );
886                                tracing::error!("Failed to spawn background process: {}", e);
887                                return;
888                            }
889                        };
890
891                        // Stream stdout
892                        let stdout = child.stdout.take();
893                        let stderr = child.stderr.take();
894                        let pid = process_id;
895
896                        // Spawn stdout reader
897                        if let Some(stdout) = stdout {
898                            let sender = sender_stdout;
899                            tokio::spawn(async move {
900                                let reader = BufReader::new(stdout);
901                                let mut lines = reader.lines();
902                                while let Ok(Some(line)) = lines.next_line().await {
903                                    let _ = sender.send(
904                                        crate::services::async_bridge::AsyncMessage::Plugin(
905                                            fresh_core::api::PluginAsyncMessage::ProcessStdout {
906                                                process_id: pid,
907                                                data: line + "\n",
908                                            },
909                                        ),
910                                    );
911                                }
912                            });
913                        }
914
915                        // Spawn stderr reader
916                        if let Some(stderr) = stderr {
917                            let sender = sender_stderr;
918                            tokio::spawn(async move {
919                                let reader = BufReader::new(stderr);
920                                let mut lines = reader.lines();
921                                while let Ok(Some(line)) = lines.next_line().await {
922                                    let _ = sender.send(
923                                        crate::services::async_bridge::AsyncMessage::Plugin(
924                                            fresh_core::api::PluginAsyncMessage::ProcessStderr {
925                                                process_id: pid,
926                                                data: line + "\n",
927                                            },
928                                        ),
929                                    );
930                                }
931                            });
932                        }
933
934                        // Wait for process to complete
935                        let exit_code = match child.wait().await {
936                            Ok(status) => status.code().unwrap_or(-1),
937                            Err(_) => -1,
938                        };
939
940                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
941                            fresh_core::api::PluginAsyncMessage::ProcessExit {
942                                process_id,
943                                callback_id: callback_id_u64,
944                                exit_code,
945                            },
946                        ));
947                    });
948
949                    // Store abort handle for potential kill
950                    self.background_process_handles
951                        .insert(process_id, handle.abort_handle());
952                } else {
953                    // No runtime - reject immediately
954                    self.plugin_manager
955                        .reject_callback(callback_id, "Async runtime not available".to_string());
956                }
957            }
958
959            PluginCommand::KillBackgroundProcess { process_id } => {
960                if let Some(handle) = self.background_process_handles.remove(&process_id) {
961                    handle.abort();
962                    tracing::debug!("Killed background process {}", process_id);
963                }
964            }
965
966            // ==================== Virtual Buffer Commands (complex, kept inline) ====================
967            PluginCommand::CreateVirtualBuffer {
968                name,
969                mode,
970                read_only,
971            } => {
972                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
973                tracing::info!(
974                    "Created virtual buffer '{}' with mode '{}' (id={:?})",
975                    name,
976                    mode,
977                    buffer_id
978                );
979                // TODO: Return buffer_id to plugin via callback or hook
980            }
981            PluginCommand::CreateVirtualBufferWithContent {
982                name,
983                mode,
984                read_only,
985                entries,
986                show_line_numbers,
987                show_cursors,
988                editing_disabled,
989                hidden_from_tabs,
990                request_id,
991            } => {
992                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
993                tracing::info!(
994                    "Created virtual buffer '{}' with mode '{}' (id={:?})",
995                    name,
996                    mode,
997                    buffer_id
998                );
999
1000                // Apply view options to the buffer
1001                // TODO: show_line_numbers is duplicated between EditorState.margins and
1002                // BufferViewState. The renderer reads BufferViewState and overwrites
1003                // margins each frame via configure_for_line_numbers(), making the margin
1004                // setting here effectively write-only. Consider removing the margin call
1005                // and only setting BufferViewState.show_line_numbers.
1006                if let Some(state) = self.buffers.get_mut(&buffer_id) {
1007                    state.margins.configure_for_line_numbers(show_line_numbers);
1008                    state.show_cursors = show_cursors;
1009                    state.editing_disabled = editing_disabled;
1010                    tracing::debug!(
1011                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
1012                        buffer_id,
1013                        show_line_numbers,
1014                        show_cursors,
1015                        editing_disabled
1016                    );
1017                }
1018                let active_split = self.split_manager.active_split();
1019                if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1020                    view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
1021                }
1022
1023                // Apply hidden_from_tabs to buffer metadata
1024                if hidden_from_tabs {
1025                    if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
1026                        meta.hidden_from_tabs = true;
1027                    }
1028                }
1029
1030                // Now set the content
1031                match self.set_virtual_buffer_content(buffer_id, entries) {
1032                    Ok(()) => {
1033                        tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
1034                        // Switch to the new buffer to display it
1035                        self.set_active_buffer(buffer_id);
1036                        tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
1037
1038                        // Send response if request_id is present
1039                        if let Some(req_id) = request_id {
1040                            tracing::info!(
1041                                "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
1042                                req_id,
1043                                buffer_id
1044                            );
1045                            // createVirtualBuffer returns VirtualBufferResult: { bufferId, splitId }
1046                            let result = fresh_core::api::VirtualBufferResult {
1047                                buffer_id: buffer_id.0 as u64,
1048                                split_id: None,
1049                            };
1050                            self.plugin_manager.resolve_callback(
1051                                fresh_core::api::JsCallbackId::from(req_id),
1052                                serde_json::to_string(&result).unwrap_or_default(),
1053                            );
1054                            tracing::info!("CreateVirtualBufferWithContent: resolve_callback sent for request_id={}", req_id);
1055                        }
1056                    }
1057                    Err(e) => {
1058                        tracing::error!("Failed to set virtual buffer content: {}", e);
1059                    }
1060                }
1061            }
1062            PluginCommand::CreateVirtualBufferInSplit {
1063                name,
1064                mode,
1065                read_only,
1066                entries,
1067                ratio,
1068                direction,
1069                panel_id,
1070                show_line_numbers,
1071                show_cursors,
1072                editing_disabled,
1073                line_wrap,
1074                before,
1075                request_id,
1076            } => {
1077                // Check if this panel already exists (for idempotent operations)
1078                if let Some(pid) = &panel_id {
1079                    if let Some(&existing_buffer_id) = self.panel_ids.get(pid) {
1080                        // Verify the buffer actually exists (defensive check for stale entries)
1081                        if self.buffers.contains_key(&existing_buffer_id) {
1082                            // Panel exists, just update its content
1083                            if let Err(e) =
1084                                self.set_virtual_buffer_content(existing_buffer_id, entries)
1085                            {
1086                                tracing::error!("Failed to update panel content: {}", e);
1087                            } else {
1088                                tracing::info!("Updated existing panel '{}' content", pid);
1089                            }
1090
1091                            // Find and focus the split that contains this buffer
1092                            let splits = self.split_manager.splits_for_buffer(existing_buffer_id);
1093                            if let Some(&split_id) = splits.first() {
1094                                self.split_manager.set_active_split(split_id);
1095                                // NOTE: active_buffer is derived from split_manager,
1096                                // but we need to ensure the split shows the right buffer
1097                                self.split_manager.set_active_buffer_id(existing_buffer_id);
1098                                tracing::debug!(
1099                                    "Focused split {:?} containing panel buffer",
1100                                    split_id
1101                                );
1102                            }
1103
1104                            // Send response with existing buffer ID and split ID via callback resolution
1105                            if let Some(req_id) = request_id {
1106                                let result = fresh_core::api::VirtualBufferResult {
1107                                    buffer_id: existing_buffer_id.0 as u64,
1108                                    split_id: splits.first().map(|s| s.0 .0 as u64),
1109                                };
1110                                self.plugin_manager.resolve_callback(
1111                                    fresh_core::api::JsCallbackId::from(req_id),
1112                                    serde_json::to_string(&result).unwrap_or_default(),
1113                                );
1114                            }
1115                            return Ok(());
1116                        } else {
1117                            // Buffer no longer exists, remove stale panel_id entry
1118                            tracing::warn!(
1119                                "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
1120                                pid,
1121                                existing_buffer_id
1122                            );
1123                            self.panel_ids.remove(pid);
1124                            // Fall through to create a new buffer
1125                        }
1126                    }
1127                }
1128
1129                // Capture the source split before creating the buffer —
1130                // `create_virtual_buffer` unconditionally adds the new buffer
1131                // as a tab to the currently active split, which is the wrong
1132                // thing for a panel that lives in its own dedicated split
1133                // (it would show up as a tab in BOTH splits — see bug #3).
1134                let source_split_before_create = self.split_manager.active_split();
1135
1136                // Create the virtual buffer first
1137                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1138                tracing::info!(
1139                    "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
1140                    name,
1141                    mode,
1142                    buffer_id
1143                );
1144
1145                // Apply view options to the buffer
1146                if let Some(state) = self.buffers.get_mut(&buffer_id) {
1147                    state.margins.configure_for_line_numbers(show_line_numbers);
1148                    state.show_cursors = show_cursors;
1149                    state.editing_disabled = editing_disabled;
1150                    tracing::debug!(
1151                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
1152                        buffer_id,
1153                        show_line_numbers,
1154                        show_cursors,
1155                        editing_disabled
1156                    );
1157                }
1158
1159                // Store the panel ID mapping if provided
1160                if let Some(pid) = panel_id {
1161                    self.panel_ids.insert(pid, buffer_id);
1162                }
1163
1164                // Set the content
1165                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1166                    tracing::error!("Failed to set virtual buffer content: {}", e);
1167                    return Ok(());
1168                }
1169
1170                // Determine split direction
1171                let split_dir = match direction.as_deref() {
1172                    Some("vertical") => crate::model::event::SplitDirection::Vertical,
1173                    _ => crate::model::event::SplitDirection::Horizontal,
1174                };
1175
1176                // Create a split with the new buffer
1177                let created_split_id = match self
1178                    .split_manager
1179                    .split_active_positioned(split_dir, buffer_id, ratio, before)
1180                {
1181                    Ok(new_split_id) => {
1182                        // The buffer now lives in its own split, so drop its
1183                        // tab from the source split (see bug #3).  Only do
1184                        // this when the new split actually differs from the
1185                        // source split — otherwise we'd leave no split
1186                        // displaying the buffer.
1187                        if new_split_id != source_split_before_create {
1188                            if let Some(source_view_state) =
1189                                self.split_view_states.get_mut(&source_split_before_create)
1190                            {
1191                                source_view_state.remove_buffer(buffer_id);
1192                            }
1193                        }
1194                        // Create independent view state for the new split with the buffer in tabs
1195                        let mut view_state = SplitViewState::with_buffer(
1196                            self.terminal_width,
1197                            self.terminal_height,
1198                            buffer_id,
1199                        );
1200                        view_state.apply_config_defaults(
1201                            self.config.editor.line_numbers,
1202                            self.config.editor.highlight_current_line,
1203                            line_wrap
1204                                .unwrap_or_else(|| self.resolve_line_wrap_for_buffer(buffer_id)),
1205                            self.config.editor.wrap_indent,
1206                            self.resolve_wrap_column_for_buffer(buffer_id),
1207                            self.config.editor.rulers.clone(),
1208                        );
1209                        // Override with plugin-requested show_line_numbers
1210                        view_state.ensure_buffer_state(buffer_id).show_line_numbers =
1211                            show_line_numbers;
1212                        self.split_view_states.insert(new_split_id, view_state);
1213
1214                        // Focus the new split (the diagnostics panel)
1215                        self.split_manager.set_active_split(new_split_id);
1216                        // NOTE: split tree was updated by split_active, active_buffer derives from it
1217
1218                        tracing::info!(
1219                            "Created {:?} split with virtual buffer {:?}",
1220                            split_dir,
1221                            buffer_id
1222                        );
1223                        Some(new_split_id)
1224                    }
1225                    Err(e) => {
1226                        tracing::error!("Failed to create split: {}", e);
1227                        // Fall back to just switching to the buffer
1228                        self.set_active_buffer(buffer_id);
1229                        None
1230                    }
1231                };
1232
1233                // Send response with buffer ID and split ID via callback resolution
1234                // NOTE: Using VirtualBufferResult type for type-safe JSON serialization
1235                if let Some(req_id) = request_id {
1236                    tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
1237                    let result = fresh_core::api::VirtualBufferResult {
1238                        buffer_id: buffer_id.0 as u64,
1239                        split_id: created_split_id.map(|s| s.0 .0 as u64),
1240                    };
1241                    self.plugin_manager.resolve_callback(
1242                        fresh_core::api::JsCallbackId::from(req_id),
1243                        serde_json::to_string(&result).unwrap_or_default(),
1244                    );
1245                }
1246            }
1247            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1248                match self.set_virtual_buffer_content(buffer_id, entries) {
1249                    Ok(()) => {
1250                        tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
1251                    }
1252                    Err(e) => {
1253                        tracing::error!("Failed to set virtual buffer content: {}", e);
1254                    }
1255                }
1256            }
1257            PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1258                // Get text properties at cursor and fire a hook with the data
1259                if let Some(state) = self.buffers.get(&buffer_id) {
1260                    let cursor_pos = self
1261                        .split_view_states
1262                        .values()
1263                        .find_map(|vs| vs.buffer_state(buffer_id))
1264                        .map(|bs| bs.cursors.primary().position)
1265                        .unwrap_or(0);
1266                    let properties = state.text_properties.get_at(cursor_pos);
1267                    tracing::debug!(
1268                        "Text properties at cursor in {:?}: {} properties found",
1269                        buffer_id,
1270                        properties.len()
1271                    );
1272                    // TODO: Fire hook with properties data for plugins to consume
1273                }
1274            }
1275            PluginCommand::CreateVirtualBufferInExistingSplit {
1276                name,
1277                mode,
1278                read_only,
1279                entries,
1280                split_id,
1281                show_line_numbers,
1282                show_cursors,
1283                editing_disabled,
1284                line_wrap,
1285                request_id,
1286            } => {
1287                // Create the virtual buffer
1288                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1289                tracing::info!(
1290                    "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
1291                    name,
1292                    mode,
1293                    split_id,
1294                    buffer_id
1295                );
1296
1297                // Apply view options to the buffer
1298                if let Some(state) = self.buffers.get_mut(&buffer_id) {
1299                    state.margins.configure_for_line_numbers(show_line_numbers);
1300                    state.show_cursors = show_cursors;
1301                    state.editing_disabled = editing_disabled;
1302                }
1303
1304                // Set the content
1305                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1306                    tracing::error!("Failed to set virtual buffer content: {}", e);
1307                    return Ok(());
1308                }
1309
1310                // Show the buffer in the target split
1311                let leaf_id = LeafId(split_id);
1312                self.split_manager.set_split_buffer(leaf_id, buffer_id);
1313
1314                // Focus the target split and set its buffer
1315                self.split_manager.set_active_split(leaf_id);
1316                self.split_manager.set_active_buffer_id(buffer_id);
1317
1318                // Switch per-buffer view state in the target split
1319                if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
1320                    view_state.switch_buffer(buffer_id);
1321                    view_state.add_buffer(buffer_id);
1322                    view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
1323
1324                    // Apply line_wrap setting if provided
1325                    if let Some(wrap) = line_wrap {
1326                        view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
1327                    }
1328                }
1329
1330                tracing::info!(
1331                    "Displayed virtual buffer {:?} in split {:?}",
1332                    buffer_id,
1333                    split_id
1334                );
1335
1336                // Send response with buffer ID and split ID via callback resolution
1337                if let Some(req_id) = request_id {
1338                    let result = fresh_core::api::VirtualBufferResult {
1339                        buffer_id: buffer_id.0 as u64,
1340                        split_id: Some(split_id.0 as u64),
1341                    };
1342                    self.plugin_manager.resolve_callback(
1343                        fresh_core::api::JsCallbackId::from(req_id),
1344                        serde_json::to_string(&result).unwrap_or_default(),
1345                    );
1346                }
1347            }
1348
1349            // ==================== Context Commands ====================
1350            PluginCommand::SetContext { name, active } => {
1351                if active {
1352                    self.active_custom_contexts.insert(name.clone());
1353                    tracing::debug!("Set custom context: {}", name);
1354                } else {
1355                    self.active_custom_contexts.remove(&name);
1356                    tracing::debug!("Unset custom context: {}", name);
1357                }
1358            }
1359
1360            // ==================== Review Diff Commands ====================
1361            PluginCommand::SetReviewDiffHunks { hunks } => {
1362                self.review_hunks = hunks;
1363                tracing::debug!("Set {} review hunks", self.review_hunks.len());
1364            }
1365
1366            // ==================== Vi Mode Commands ====================
1367            PluginCommand::ExecuteAction { action_name } => {
1368                self.handle_execute_action(action_name);
1369            }
1370            PluginCommand::ExecuteActions { actions } => {
1371                self.handle_execute_actions(actions);
1372            }
1373            PluginCommand::GetBufferText {
1374                buffer_id,
1375                start,
1376                end,
1377                request_id,
1378            } => {
1379                self.handle_get_buffer_text(buffer_id, start, end, request_id);
1380            }
1381            PluginCommand::GetLineStartPosition {
1382                buffer_id,
1383                line,
1384                request_id,
1385            } => {
1386                self.handle_get_line_start_position(buffer_id, line, request_id);
1387            }
1388            PluginCommand::GetLineEndPosition {
1389                buffer_id,
1390                line,
1391                request_id,
1392            } => {
1393                self.handle_get_line_end_position(buffer_id, line, request_id);
1394            }
1395            PluginCommand::GetBufferLineCount {
1396                buffer_id,
1397                request_id,
1398            } => {
1399                self.handle_get_buffer_line_count(buffer_id, request_id);
1400            }
1401            PluginCommand::ScrollToLineCenter {
1402                split_id,
1403                buffer_id,
1404                line,
1405            } => {
1406                self.handle_scroll_to_line_center(split_id, buffer_id, line);
1407            }
1408            PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1409                self.handle_scroll_buffer_to_line(buffer_id, line);
1410            }
1411            PluginCommand::SetEditorMode { mode } => {
1412                self.handle_set_editor_mode(mode);
1413            }
1414
1415            // ==================== LSP Helper Commands ====================
1416            PluginCommand::ShowActionPopup {
1417                popup_id,
1418                title,
1419                message,
1420                actions,
1421            } => {
1422                tracing::info!(
1423                    "Action popup requested: id={}, title={}, actions={}",
1424                    popup_id,
1425                    title,
1426                    actions.len()
1427                );
1428
1429                // Build popup list items from actions
1430                let items: Vec<crate::model::event::PopupListItemData> = actions
1431                    .iter()
1432                    .map(|action| crate::model::event::PopupListItemData {
1433                        text: action.label.clone(),
1434                        detail: None,
1435                        icon: None,
1436                        data: Some(action.id.clone()),
1437                    })
1438                    .collect();
1439
1440                // Store action info for when popup is confirmed/cancelled
1441                let action_ids: Vec<(String, String)> =
1442                    actions.into_iter().map(|a| (a.id, a.label)).collect();
1443                self.active_action_popup = Some((popup_id.clone(), action_ids));
1444
1445                // Create popup with message + action list
1446                let popup = crate::model::event::PopupData {
1447                    kind: crate::model::event::PopupKindHint::List,
1448                    title: Some(title),
1449                    description: Some(message),
1450                    transient: false,
1451                    content: crate::model::event::PopupContentData::List { items, selected: 0 },
1452                    position: crate::model::event::PopupPositionData::BottomRight,
1453                    width: 60,
1454                    max_height: 15,
1455                    bordered: true,
1456                };
1457
1458                self.show_popup(popup);
1459                tracing::info!(
1460                    "Action popup shown: id={}, active_action_popup={:?}",
1461                    popup_id,
1462                    self.active_action_popup.as_ref().map(|(id, _)| id)
1463                );
1464            }
1465
1466            PluginCommand::DisableLspForLanguage { language } => {
1467                tracing::info!("Disabling LSP for language: {}", language);
1468
1469                // 1. Stop the LSP server for this language if running
1470                if let Some(ref mut lsp) = self.lsp {
1471                    lsp.shutdown_server(&language);
1472                    tracing::info!("Stopped LSP server for {}", language);
1473                }
1474
1475                // 2. Update the config to disable the language
1476                if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
1477                    for c in lsp_configs.as_mut_slice() {
1478                        c.enabled = false;
1479                        c.auto_start = false;
1480                    }
1481                    tracing::info!("Disabled LSP config for {}", language);
1482                }
1483
1484                // 3. Persist the config change
1485                if let Err(e) = self.save_config() {
1486                    tracing::error!("Failed to save config: {}", e);
1487                    self.status_message = Some(format!(
1488                        "LSP disabled for {} (config save failed)",
1489                        language
1490                    ));
1491                } else {
1492                    self.status_message = Some(format!("LSP disabled for {}", language));
1493                }
1494
1495                // 4. Clear any LSP-related warnings for this language
1496                self.warning_domains.lsp.clear();
1497            }
1498
1499            PluginCommand::RestartLspForLanguage { language } => {
1500                tracing::info!("Plugin restarting LSP for language: {}", language);
1501
1502                let file_path = self
1503                    .buffer_metadata
1504                    .get(&self.active_buffer())
1505                    .and_then(|meta| meta.file_path().cloned());
1506                let success = if let Some(ref mut lsp) = self.lsp {
1507                    let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
1508                    self.status_message = Some(msg);
1509                    ok
1510                } else {
1511                    self.status_message = Some("No LSP manager available".to_string());
1512                    false
1513                };
1514
1515                if success {
1516                    self.reopen_buffers_for_language(&language);
1517                }
1518            }
1519
1520            PluginCommand::SetLspRootUri { language, uri } => {
1521                tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
1522
1523                // Parse the URI string into an lsp_types::Uri
1524                match uri.parse::<lsp_types::Uri>() {
1525                    Ok(parsed_uri) => {
1526                        if let Some(ref mut lsp) = self.lsp {
1527                            let restarted = lsp.set_language_root_uri(&language, parsed_uri);
1528                            if restarted {
1529                                self.status_message = Some(format!(
1530                                    "LSP root updated for {} (restarting server)",
1531                                    language
1532                                ));
1533                            } else {
1534                                self.status_message =
1535                                    Some(format!("LSP root set for {}", language));
1536                            }
1537                        }
1538                    }
1539                    Err(e) => {
1540                        tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
1541                        self.status_message = Some(format!("Invalid LSP root URI: {}", e));
1542                    }
1543                }
1544            }
1545
1546            // ==================== Scroll Sync Commands ====================
1547            PluginCommand::CreateScrollSyncGroup {
1548                group_id,
1549                left_split,
1550                right_split,
1551            } => {
1552                let success = self.scroll_sync_manager.create_group_with_id(
1553                    group_id,
1554                    left_split,
1555                    right_split,
1556                );
1557                if success {
1558                    tracing::debug!(
1559                        "Created scroll sync group {} for splits {:?} and {:?}",
1560                        group_id,
1561                        left_split,
1562                        right_split
1563                    );
1564                } else {
1565                    tracing::warn!(
1566                        "Failed to create scroll sync group {} (ID already exists)",
1567                        group_id
1568                    );
1569                }
1570            }
1571            PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1572                use crate::view::scroll_sync::SyncAnchor;
1573                let anchor_count = anchors.len();
1574                let sync_anchors: Vec<SyncAnchor> = anchors
1575                    .into_iter()
1576                    .map(|(left_line, right_line)| SyncAnchor {
1577                        left_line,
1578                        right_line,
1579                    })
1580                    .collect();
1581                self.scroll_sync_manager.set_anchors(group_id, sync_anchors);
1582                tracing::debug!(
1583                    "Set {} anchors for scroll sync group {}",
1584                    anchor_count,
1585                    group_id
1586                );
1587            }
1588            PluginCommand::RemoveScrollSyncGroup { group_id } => {
1589                if self.scroll_sync_manager.remove_group(group_id) {
1590                    tracing::debug!("Removed scroll sync group {}", group_id);
1591                } else {
1592                    tracing::warn!("Scroll sync group {} not found", group_id);
1593                }
1594            }
1595
1596            // ==================== Composite Buffer Commands ====================
1597            PluginCommand::CreateCompositeBuffer {
1598                name,
1599                mode,
1600                layout,
1601                sources,
1602                hunks,
1603                initial_focus_hunk,
1604                request_id,
1605            } => {
1606                self.handle_create_composite_buffer(
1607                    name,
1608                    mode,
1609                    layout,
1610                    sources,
1611                    hunks,
1612                    initial_focus_hunk,
1613                    request_id,
1614                );
1615            }
1616            PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1617                self.handle_update_composite_alignment(buffer_id, hunks);
1618            }
1619            PluginCommand::CloseCompositeBuffer { buffer_id } => {
1620                self.close_composite_buffer(buffer_id);
1621            }
1622            PluginCommand::FlushLayout => {
1623                self.flush_layout();
1624            }
1625            PluginCommand::CompositeNextHunk { buffer_id } => {
1626                let split_id = self.split_manager.active_split();
1627                self.composite_next_hunk(split_id, buffer_id);
1628            }
1629            PluginCommand::CompositePrevHunk { buffer_id } => {
1630                let split_id = self.split_manager.active_split();
1631                self.composite_prev_hunk(split_id, buffer_id);
1632            }
1633
1634            // ==================== Buffer Groups ====================
1635            PluginCommand::CreateBufferGroup {
1636                name,
1637                mode,
1638                layout_json,
1639                request_id,
1640            } => match self.create_buffer_group(name, mode, layout_json) {
1641                Ok(result) => {
1642                    if let Some(req_id) = request_id {
1643                        let json = serde_json::to_string(&result).unwrap_or_default();
1644                        self.plugin_manager
1645                            .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
1646                    }
1647                }
1648                Err(e) => {
1649                    tracing::error!("Failed to create buffer group: {}", e);
1650                }
1651            },
1652            PluginCommand::SetPanelContent {
1653                group_id,
1654                panel_name,
1655                entries,
1656            } => {
1657                self.set_panel_content(group_id, panel_name, entries);
1658            }
1659            PluginCommand::CloseBufferGroup { group_id } => {
1660                self.close_buffer_group(group_id);
1661            }
1662            PluginCommand::FocusPanel {
1663                group_id,
1664                panel_name,
1665            } => {
1666                self.focus_panel(group_id, panel_name);
1667            }
1668
1669            // ==================== File Operations ====================
1670            PluginCommand::SaveBufferToPath { buffer_id, path } => {
1671                self.handle_save_buffer_to_path(buffer_id, path);
1672            }
1673
1674            // ==================== Plugin Management ====================
1675            #[cfg(feature = "plugins")]
1676            PluginCommand::LoadPlugin { path, callback_id } => {
1677                self.handle_load_plugin(path, callback_id);
1678            }
1679            #[cfg(feature = "plugins")]
1680            PluginCommand::UnloadPlugin { name, callback_id } => {
1681                self.handle_unload_plugin(name, callback_id);
1682            }
1683            #[cfg(feature = "plugins")]
1684            PluginCommand::ReloadPlugin { name, callback_id } => {
1685                self.handle_reload_plugin(name, callback_id);
1686            }
1687            #[cfg(feature = "plugins")]
1688            PluginCommand::ListPlugins { callback_id } => {
1689                self.handle_list_plugins(callback_id);
1690            }
1691            // When plugins feature is disabled, these commands are no-ops
1692            #[cfg(not(feature = "plugins"))]
1693            PluginCommand::LoadPlugin { .. }
1694            | PluginCommand::UnloadPlugin { .. }
1695            | PluginCommand::ReloadPlugin { .. }
1696            | PluginCommand::ListPlugins { .. } => {
1697                tracing::warn!("Plugin management commands require the 'plugins' feature");
1698            }
1699
1700            // ==================== Terminal Commands ====================
1701            PluginCommand::CreateTerminal {
1702                cwd,
1703                direction,
1704                ratio,
1705                focus,
1706                request_id,
1707            } => {
1708                let (cols, rows) = self.get_terminal_dimensions();
1709
1710                // Set up async bridge for terminal manager if not already done
1711                if let Some(ref bridge) = self.async_bridge {
1712                    self.terminal_manager.set_async_bridge(bridge.clone());
1713                }
1714
1715                // Determine working directory
1716                let working_dir = cwd
1717                    .map(std::path::PathBuf::from)
1718                    .unwrap_or_else(|| self.working_dir.clone());
1719
1720                // Prepare persistent storage paths
1721                let terminal_root = self.dir_context.terminal_dir_for(&working_dir);
1722                if let Err(e) = self.filesystem.create_dir_all(&terminal_root) {
1723                    tracing::warn!("Failed to create terminal directory: {}", e);
1724                }
1725                let predicted_terminal_id = self.terminal_manager.next_terminal_id();
1726                let log_path =
1727                    terminal_root.join(format!("fresh-terminal-{}.log", predicted_terminal_id.0));
1728                let backing_path =
1729                    terminal_root.join(format!("fresh-terminal-{}.txt", predicted_terminal_id.0));
1730                self.terminal_backing_files
1731                    .insert(predicted_terminal_id, backing_path);
1732                let backing_path_for_spawn = self
1733                    .terminal_backing_files
1734                    .get(&predicted_terminal_id)
1735                    .cloned();
1736
1737                match self.terminal_manager.spawn(
1738                    cols,
1739                    rows,
1740                    Some(working_dir),
1741                    Some(log_path.clone()),
1742                    backing_path_for_spawn,
1743                ) {
1744                    Ok(terminal_id) => {
1745                        // Track log file path
1746                        self.terminal_log_files
1747                            .insert(terminal_id, log_path.clone());
1748                        // Fix up backing path if predicted ID differs
1749                        if terminal_id != predicted_terminal_id {
1750                            self.terminal_backing_files.remove(&predicted_terminal_id);
1751                            let backing_path =
1752                                terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0));
1753                            self.terminal_backing_files
1754                                .insert(terminal_id, backing_path);
1755                        }
1756
1757                        // Create buffer attached to the active split
1758                        let active_split = self.split_manager.active_split();
1759                        let buffer_id =
1760                            self.create_terminal_buffer_attached(terminal_id, active_split);
1761
1762                        // If direction is specified, create a new split for the terminal.
1763                        // If direction is None, just place the terminal in the active split
1764                        // (no new split created — useful when the plugin manages layout).
1765                        let created_split_id = if let Some(dir_str) = direction.as_deref() {
1766                            let split_dir = match dir_str {
1767                                "horizontal" => crate::model::event::SplitDirection::Horizontal,
1768                                _ => crate::model::event::SplitDirection::Vertical,
1769                            };
1770
1771                            let split_ratio = ratio.unwrap_or(0.5);
1772                            match self
1773                                .split_manager
1774                                .split_active(split_dir, buffer_id, split_ratio)
1775                            {
1776                                Ok(new_split_id) => {
1777                                    let mut view_state = SplitViewState::with_buffer(
1778                                        self.terminal_width,
1779                                        self.terminal_height,
1780                                        buffer_id,
1781                                    );
1782                                    view_state.apply_config_defaults(
1783                                        self.config.editor.line_numbers,
1784                                        self.config.editor.highlight_current_line,
1785                                        false,
1786                                        false,
1787                                        None,
1788                                        self.config.editor.rulers.clone(),
1789                                    );
1790                                    self.split_view_states.insert(new_split_id, view_state);
1791
1792                                    if focus.unwrap_or(true) {
1793                                        self.split_manager.set_active_split(new_split_id);
1794                                    }
1795
1796                                    tracing::info!(
1797                                        "Created {:?} split for terminal {:?} with buffer {:?}",
1798                                        split_dir,
1799                                        terminal_id,
1800                                        buffer_id
1801                                    );
1802                                    Some(new_split_id)
1803                                }
1804                                Err(e) => {
1805                                    tracing::error!("Failed to create split for terminal: {}", e);
1806                                    self.set_active_buffer(buffer_id);
1807                                    None
1808                                }
1809                            }
1810                        } else {
1811                            // No split — just switch to the terminal buffer in the active split
1812                            self.set_active_buffer(buffer_id);
1813                            None
1814                        };
1815
1816                        // Resize terminal to match actual split content area
1817                        self.resize_visible_terminals();
1818
1819                        // Resolve the callback with TerminalResult
1820                        let result = fresh_core::api::TerminalResult {
1821                            buffer_id: buffer_id.0 as u64,
1822                            terminal_id: terminal_id.0 as u64,
1823                            split_id: created_split_id.map(|s| s.0 .0 as u64),
1824                        };
1825                        self.plugin_manager.resolve_callback(
1826                            fresh_core::api::JsCallbackId::from(request_id),
1827                            serde_json::to_string(&result).unwrap_or_default(),
1828                        );
1829
1830                        tracing::info!(
1831                            "Plugin created terminal {:?} with buffer {:?}",
1832                            terminal_id,
1833                            buffer_id
1834                        );
1835                    }
1836                    Err(e) => {
1837                        tracing::error!("Failed to create terminal for plugin: {}", e);
1838                        self.plugin_manager.reject_callback(
1839                            fresh_core::api::JsCallbackId::from(request_id),
1840                            format!("Failed to create terminal: {}", e),
1841                        );
1842                    }
1843                }
1844            }
1845
1846            PluginCommand::SendTerminalInput { terminal_id, data } => {
1847                if let Some(handle) = self.terminal_manager.get(terminal_id) {
1848                    handle.write(data.as_bytes());
1849                    tracing::trace!(
1850                        "Plugin sent {} bytes to terminal {:?}",
1851                        data.len(),
1852                        terminal_id
1853                    );
1854                } else {
1855                    tracing::warn!(
1856                        "Plugin tried to send input to non-existent terminal {:?}",
1857                        terminal_id
1858                    );
1859                }
1860            }
1861
1862            PluginCommand::CloseTerminal { terminal_id } => {
1863                // Find and close the buffer associated with this terminal
1864                let buffer_to_close = self
1865                    .terminal_buffers
1866                    .iter()
1867                    .find(|(_, &tid)| tid == terminal_id)
1868                    .map(|(&bid, _)| bid);
1869
1870                if let Some(buffer_id) = buffer_to_close {
1871                    if let Err(e) = self.close_buffer(buffer_id) {
1872                        tracing::warn!("Failed to close terminal buffer: {}", e);
1873                    }
1874                    tracing::info!("Plugin closed terminal {:?}", terminal_id);
1875                } else {
1876                    // Terminal exists but no buffer — just close the terminal directly
1877                    self.terminal_manager.close(terminal_id);
1878                    tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
1879                }
1880            }
1881
1882            PluginCommand::GrepProject {
1883                pattern,
1884                fixed_string,
1885                case_sensitive,
1886                max_results,
1887                whole_words,
1888                callback_id,
1889            } => {
1890                self.handle_grep_project(
1891                    pattern,
1892                    fixed_string,
1893                    case_sensitive,
1894                    max_results,
1895                    whole_words,
1896                    callback_id,
1897                );
1898            }
1899
1900            PluginCommand::GrepProjectStreaming {
1901                pattern,
1902                fixed_string,
1903                case_sensitive,
1904                max_results,
1905                whole_words,
1906                search_id,
1907                callback_id,
1908            } => {
1909                self.handle_grep_project_streaming(
1910                    pattern,
1911                    fixed_string,
1912                    case_sensitive,
1913                    max_results,
1914                    whole_words,
1915                    search_id,
1916                    callback_id,
1917                );
1918            }
1919
1920            PluginCommand::ReplaceInBuffer {
1921                file_path,
1922                matches,
1923                replacement,
1924                callback_id,
1925            } => {
1926                self.handle_replace_in_buffer(file_path, matches, replacement, callback_id);
1927            }
1928        }
1929        Ok(())
1930    }
1931
1932    /// Save a buffer to a specific file path (for :w filename)
1933    fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1934        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1935            // Save to the specified path
1936            match state.buffer.save_to_file(&path) {
1937                Ok(()) => {
1938                    // save_to_file already updates file_path internally via finalize_save
1939                    // Run on-save actions (formatting, etc.)
1940                    if let Err(e) = self.finalize_save(Some(path)) {
1941                        tracing::warn!("Failed to finalize save: {}", e);
1942                    }
1943                    tracing::debug!("Saved buffer {:?} to path", buffer_id);
1944                }
1945                Err(e) => {
1946                    self.handle_set_status(format!("Error saving: {}", e));
1947                    tracing::error!("Failed to save buffer to path: {}", e);
1948                }
1949            }
1950        } else {
1951            self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1952            tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1953        }
1954    }
1955
1956    /// Load a plugin from a file path
1957    #[cfg(feature = "plugins")]
1958    fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1959        match self.plugin_manager.load_plugin(&path) {
1960            Ok(()) => {
1961                tracing::info!("Loaded plugin from {:?}", path);
1962                self.plugin_manager
1963                    .resolve_callback(callback_id, "true".to_string());
1964            }
1965            Err(e) => {
1966                tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1967                self.plugin_manager
1968                    .reject_callback(callback_id, format!("{}", e));
1969            }
1970        }
1971    }
1972
1973    /// Unload a plugin by name
1974    #[cfg(feature = "plugins")]
1975    fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1976        match self.plugin_manager.unload_plugin(&name) {
1977            Ok(()) => {
1978                tracing::info!("Unloaded plugin: {}", name);
1979                self.plugin_manager
1980                    .resolve_callback(callback_id, "true".to_string());
1981            }
1982            Err(e) => {
1983                tracing::error!("Failed to unload plugin '{}': {}", name, e);
1984                self.plugin_manager
1985                    .reject_callback(callback_id, format!("{}", e));
1986            }
1987        }
1988    }
1989
1990    /// Reload a plugin by name
1991    #[cfg(feature = "plugins")]
1992    fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1993        match self.plugin_manager.reload_plugin(&name) {
1994            Ok(()) => {
1995                tracing::info!("Reloaded plugin: {}", name);
1996                self.plugin_manager
1997                    .resolve_callback(callback_id, "true".to_string());
1998            }
1999            Err(e) => {
2000                tracing::error!("Failed to reload plugin '{}': {}", name, e);
2001                self.plugin_manager
2002                    .reject_callback(callback_id, format!("{}", e));
2003            }
2004        }
2005    }
2006
2007    /// List all loaded plugins
2008    #[cfg(feature = "plugins")]
2009    fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
2010        let plugins = self.plugin_manager.list_plugins();
2011        // Serialize to JSON array of { name, path, enabled }
2012        let json_array: Vec<serde_json::Value> = plugins
2013            .iter()
2014            .map(|p| {
2015                serde_json::json!({
2016                    "name": p.name,
2017                    "path": p.path.to_string_lossy(),
2018                    "enabled": p.enabled
2019                })
2020            })
2021            .collect();
2022        let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
2023        self.plugin_manager.resolve_callback(callback_id, json_str);
2024    }
2025
2026    /// Execute an editor action by name (for vi mode plugin)
2027    fn handle_execute_action(&mut self, action_name: String) {
2028        use crate::input::keybindings::Action;
2029        use std::collections::HashMap;
2030
2031        // Parse the action name into an Action enum
2032        if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
2033            // Execute the action
2034            if let Err(e) = self.handle_action(action) {
2035                tracing::warn!("Failed to execute action '{}': {}", action_name, e);
2036            } else {
2037                tracing::debug!("Executed action: {}", action_name);
2038            }
2039        } else {
2040            tracing::warn!("Unknown action: {}", action_name);
2041        }
2042    }
2043
2044    /// Execute multiple actions in sequence, each with an optional repeat count
2045    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
2046    fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
2047        use crate::input::keybindings::Action;
2048        use std::collections::HashMap;
2049
2050        for action_spec in actions {
2051            if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
2052                // Execute the action `count` times
2053                for _ in 0..action_spec.count {
2054                    if let Err(e) = self.handle_action(action.clone()) {
2055                        tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
2056                        return; // Stop on first error
2057                    }
2058                }
2059                tracing::debug!(
2060                    "Executed action '{}' {} time(s)",
2061                    action_spec.action,
2062                    action_spec.count
2063                );
2064            } else {
2065                tracing::warn!("Unknown action: {}", action_spec.action);
2066                return; // Stop on unknown action
2067            }
2068        }
2069    }
2070
2071    /// Get text from a buffer range (for vi mode yank operations)
2072    fn handle_get_buffer_text(
2073        &mut self,
2074        buffer_id: BufferId,
2075        start: usize,
2076        end: usize,
2077        request_id: u64,
2078    ) {
2079        let result = if let Some(state) = self.buffers.get_mut(&buffer_id) {
2080            // Get text from the buffer using the mutable get_text_range method
2081            let len = state.buffer.len();
2082            if start <= end && end <= len {
2083                Ok(state.get_text_range(start, end))
2084            } else {
2085                Err(format!(
2086                    "Invalid range {}..{} for buffer of length {}",
2087                    start, end, len
2088                ))
2089            }
2090        } else {
2091            Err(format!("Buffer {:?} not found", buffer_id))
2092        };
2093
2094        // Resolve the JavaScript Promise callback directly
2095        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2096        match result {
2097            Ok(text) => {
2098                // Serialize text as JSON string
2099                let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
2100                self.plugin_manager.resolve_callback(callback_id, json);
2101            }
2102            Err(error) => {
2103                self.plugin_manager.reject_callback(callback_id, error);
2104            }
2105        }
2106    }
2107
2108    /// Set the global editor mode (for vi mode)
2109    fn handle_set_editor_mode(&mut self, mode: Option<String>) {
2110        self.editor_mode = mode.clone();
2111        tracing::debug!("Set editor mode: {:?}", mode);
2112    }
2113
2114    /// Get the byte offset of the start of a line in the active buffer
2115    fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2116        // Use active buffer if buffer_id is 0
2117        let actual_buffer_id = if buffer_id.0 == 0 {
2118            self.active_buffer_id()
2119        } else {
2120            buffer_id
2121        };
2122
2123        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2124            // Get line start position by iterating through the buffer content
2125            let line_number = line as usize;
2126            let buffer_len = state.buffer.len();
2127
2128            if line_number == 0 {
2129                // First line always starts at 0
2130                Some(0)
2131            } else {
2132                // Count newlines to find the start of the requested line
2133                let mut current_line = 0;
2134                let mut line_start = None;
2135
2136                // Read buffer content to find newlines using the BufferState's get_text_range
2137                let content = state.get_text_range(0, buffer_len);
2138                for (byte_idx, c) in content.char_indices() {
2139                    if c == '\n' {
2140                        current_line += 1;
2141                        if current_line == line_number {
2142                            // Found the start of the requested line (byte after newline)
2143                            line_start = Some(byte_idx + 1);
2144                            break;
2145                        }
2146                    }
2147                }
2148                line_start
2149            }
2150        } else {
2151            None
2152        };
2153
2154        // Resolve the JavaScript Promise callback directly
2155        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2156        // Serialize as JSON (null for None, number for Some)
2157        let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2158        self.plugin_manager.resolve_callback(callback_id, json);
2159    }
2160
2161    /// Get the byte offset of the end of a line in the active buffer
2162    /// Returns the position after the last character of the line (before newline)
2163    fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2164        // Use active buffer if buffer_id is 0
2165        let actual_buffer_id = if buffer_id.0 == 0 {
2166            self.active_buffer_id()
2167        } else {
2168            buffer_id
2169        };
2170
2171        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2172            let line_number = line as usize;
2173            let buffer_len = state.buffer.len();
2174
2175            // Read buffer content to find line boundaries
2176            let content = state.get_text_range(0, buffer_len);
2177            let mut current_line = 0;
2178            let mut line_end = None;
2179
2180            for (byte_idx, c) in content.char_indices() {
2181                if c == '\n' {
2182                    if current_line == line_number {
2183                        // Found the end of the requested line (position of newline)
2184                        line_end = Some(byte_idx);
2185                        break;
2186                    }
2187                    current_line += 1;
2188                }
2189            }
2190
2191            // Handle last line (no trailing newline)
2192            if line_end.is_none() && current_line == line_number {
2193                line_end = Some(buffer_len);
2194            }
2195
2196            line_end
2197        } else {
2198            None
2199        };
2200
2201        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2202        let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2203        self.plugin_manager.resolve_callback(callback_id, json);
2204    }
2205
2206    /// Get the total number of lines in a buffer
2207    fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
2208        // Use active buffer if buffer_id is 0
2209        let actual_buffer_id = if buffer_id.0 == 0 {
2210            self.active_buffer_id()
2211        } else {
2212            buffer_id
2213        };
2214
2215        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2216            let buffer_len = state.buffer.len();
2217            let content = state.get_text_range(0, buffer_len);
2218
2219            // Count lines (number of newlines + 1, unless empty)
2220            if content.is_empty() {
2221                Some(1) // Empty buffer has 1 line
2222            } else {
2223                let newline_count = content.chars().filter(|&c| c == '\n').count();
2224                // If file ends with newline, don't count extra line
2225                let ends_with_newline = content.ends_with('\n');
2226                if ends_with_newline {
2227                    Some(newline_count)
2228                } else {
2229                    Some(newline_count + 1)
2230                }
2231            }
2232        } else {
2233            None
2234        };
2235
2236        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2237        let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2238        self.plugin_manager.resolve_callback(callback_id, json);
2239    }
2240
2241    /// Scroll a split to center a specific line in the viewport
2242    fn handle_scroll_to_line_center(
2243        &mut self,
2244        split_id: SplitId,
2245        buffer_id: BufferId,
2246        line: usize,
2247    ) {
2248        // Use active split if split_id is 0
2249        let actual_split_id = if split_id.0 == 0 {
2250            self.split_manager.active_split()
2251        } else {
2252            LeafId(split_id)
2253        };
2254
2255        // Use active buffer if buffer_id is 0
2256        let actual_buffer_id = if buffer_id.0 == 0 {
2257            self.active_buffer()
2258        } else {
2259            buffer_id
2260        };
2261
2262        // Get viewport height
2263        let viewport_height = if let Some(view_state) = self.split_view_states.get(&actual_split_id)
2264        {
2265            view_state.viewport.height as usize
2266        } else {
2267            return;
2268        };
2269
2270        // Calculate the target line to scroll to (center the requested line)
2271        let lines_above = viewport_height / 2;
2272        let target_line = line.saturating_sub(lines_above);
2273
2274        // Get the buffer and scroll
2275        if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2276            let buffer = &mut state.buffer;
2277            if let Some(view_state) = self.split_view_states.get_mut(&actual_split_id) {
2278                view_state.viewport.scroll_to(buffer, target_line);
2279                // Mark to skip ensure_visible on next render so the scroll isn't undone
2280                view_state.viewport.set_skip_ensure_visible();
2281            }
2282        }
2283    }
2284
2285    /// Scroll every split whose active buffer is `buffer_id` so that
2286    /// `line` is within the viewport. Used by plugin panels (buffer
2287    /// groups) whose plugin-side "selected row" doesn't drive the
2288    /// buffer cursor — after updating the selection, the plugin calls
2289    /// this to bring the selected row into view.
2290    ///
2291    /// Walks both the main split tree's leaves AND the inner leaves of
2292    /// all Grouped subtrees stored in `grouped_subtrees`, because the
2293    /// latter are not represented in `split_manager`'s tree.
2294    fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
2295        if !self.buffers.contains_key(&buffer_id) {
2296            return;
2297        }
2298
2299        // Collect the leaf ids whose active buffer is `buffer_id`.
2300        let mut target_leaves: Vec<LeafId> = Vec::new();
2301
2302        // Main tree: walk its leaves.
2303        for leaf_id in self.split_manager.root().leaf_split_ids() {
2304            if let Some(vs) = self.split_view_states.get(&leaf_id) {
2305                if vs.active_buffer == buffer_id {
2306                    target_leaves.push(leaf_id);
2307                }
2308            }
2309        }
2310
2311        // Grouped subtrees: walk each group's inner leaves.
2312        for (_group_leaf_id, node) in self.grouped_subtrees.iter() {
2313            if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
2314                for inner_leaf in layout.leaf_split_ids() {
2315                    if let Some(vs) = self.split_view_states.get(&inner_leaf) {
2316                        if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
2317                            target_leaves.push(inner_leaf);
2318                        }
2319                    }
2320                }
2321            }
2322        }
2323
2324        if target_leaves.is_empty() {
2325            return;
2326        }
2327
2328        let state = match self.buffers.get_mut(&buffer_id) {
2329            Some(s) => s,
2330            None => return,
2331        };
2332
2333        for leaf_id in target_leaves {
2334            let Some(view_state) = self.split_view_states.get_mut(&leaf_id) else {
2335                continue;
2336            };
2337            let viewport_height = view_state.viewport.height as usize;
2338            // Place `line` roughly a third of the viewport from the top so
2339            // the next few navigation steps don't immediately scroll again.
2340            let lines_above = viewport_height / 3;
2341            let target = line.saturating_sub(lines_above);
2342            view_state.viewport.scroll_to(&mut state.buffer, target);
2343            view_state.viewport.set_skip_ensure_visible();
2344        }
2345    }
2346}