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