1use std::sync::Arc;
15
16use anyhow::Result as AnyhowResult;
17
18use fresh_core::api::{BufferSavedDiff, JsCallbackId, PluginCommand};
19
20use crate::model::event::{BufferId, LeafId, SplitId};
21use crate::services::async_bridge::AsyncMessage;
22use crate::view::split::SplitViewState;
23
24use super::Editor;
25
26impl Editor {
27 #[cfg(feature = "plugins")]
29 pub(super) fn update_plugin_state_snapshot(&mut self) {
30 if let Some(snapshot_handle) = self.plugin_manager.state_snapshot_handle() {
32 use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
33 let mut snapshot = snapshot_handle.write().unwrap();
34
35 let grammar_count = self.grammar_registry.available_syntaxes().len();
37 if snapshot.available_grammars.len() != grammar_count {
38 snapshot.available_grammars = self
39 .grammar_registry
40 .available_grammar_info()
41 .into_iter()
42 .map(|g| fresh_core::api::GrammarInfoSnapshot {
43 name: g.name,
44 source: g.source.to_string(),
45 file_extensions: g.file_extensions,
46 short_name: g.short_name,
47 })
48 .collect();
49 }
50
51 snapshot.active_buffer_id = self.active_buffer();
53
54 snapshot.active_split_id = self.split_manager.active_split().0 .0;
56
57 snapshot.buffers.clear();
59 snapshot.buffer_saved_diffs.clear();
60 snapshot.buffer_cursor_positions.clear();
61 snapshot.buffer_text_properties.clear();
62
63 for (buffer_id, state) in &self.buffers {
64 let is_virtual = self
65 .buffer_metadata
66 .get(buffer_id)
67 .map(|m| m.is_virtual())
68 .unwrap_or(false);
69 let active_split = self.split_manager.active_split();
74 let active_vs = self.split_view_states.get(&active_split);
75 let view_mode = active_vs
76 .and_then(|vs| vs.buffer_state(*buffer_id))
77 .map(|bs| match bs.view_mode {
78 crate::state::ViewMode::Source => "source",
79 crate::state::ViewMode::PageView => "compose",
80 })
81 .unwrap_or("source");
82 let compose_width = active_vs
83 .and_then(|vs| vs.buffer_state(*buffer_id))
84 .and_then(|bs| bs.compose_width);
85 let is_composing_in_any_split = self.split_view_states.values().any(|vs| {
86 vs.buffer_state(*buffer_id)
87 .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
88 .unwrap_or(false)
89 });
90 let is_preview = self
91 .buffer_metadata
92 .get(buffer_id)
93 .map(|m| m.is_preview)
94 .unwrap_or(false);
95 let splits: Vec<fresh_core::SplitId> = self
101 .split_manager
102 .splits_for_buffer(*buffer_id)
103 .into_iter()
104 .map(|leaf_id| leaf_id.0)
105 .collect();
106 let buffer_info = BufferInfo {
107 id: *buffer_id,
108 path: state.buffer.file_path().map(|p| p.to_path_buf()),
109 modified: state.buffer.is_modified(),
110 length: state.buffer.len(),
111 is_virtual,
112 view_mode: view_mode.to_string(),
113 is_composing_in_any_split,
114 compose_width,
115 language: state.language.clone(),
116 is_preview,
117 splits,
118 };
119 snapshot.buffers.insert(*buffer_id, buffer_info);
120
121 let diff = {
122 let diff = state.buffer.diff_since_saved();
123 BufferSavedDiff {
124 equal: diff.equal,
125 byte_ranges: diff.byte_ranges.clone(),
126 }
127 };
128 snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
129
130 let is_hidden = self
139 .buffer_metadata
140 .get(buffer_id)
141 .is_some_and(|m| m.hidden_from_tabs);
142 let source_split = self.split_view_states.iter().find(|(split_id, vs)| {
143 vs.keyed_states.contains_key(buffer_id)
144 && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
145 });
146 let cursor_pos = source_split
147 .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
148 .map(|bs| bs.cursors.primary().position)
149 .unwrap_or(0);
150 tracing::trace!(
151 "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
152 buffer_id,
153 cursor_pos,
154 source_split.map(|(id, _)| *id),
155 );
156 snapshot
157 .buffer_cursor_positions
158 .insert(*buffer_id, cursor_pos);
159
160 if !state.text_properties.is_empty() {
162 snapshot
163 .buffer_text_properties
164 .insert(*buffer_id, state.text_properties.all().to_vec());
165 }
166 }
167
168 if let Some(active_vs) = self
170 .split_view_states
171 .get(&self.split_manager.active_split())
172 {
173 let active_cursors = &active_vs.cursors;
175 let primary = active_cursors.primary();
176 let primary_position = primary.position;
177 let primary_selection = primary.selection_range();
178
179 snapshot.primary_cursor = Some(CursorInfo {
180 position: primary_position,
181 selection: primary_selection.clone(),
182 });
183
184 snapshot.all_cursors = active_cursors
186 .iter()
187 .map(|(_, cursor)| CursorInfo {
188 position: cursor.position,
189 selection: cursor.selection_range(),
190 })
191 .collect();
192
193 if let Some(range) = primary_selection {
195 if let Some(active_state) = self.buffers.get_mut(&self.active_buffer()) {
196 snapshot.selected_text =
197 Some(active_state.get_text_range(range.start, range.end));
198 }
199 }
200
201 let top_line = self.buffers.get(&self.active_buffer()).and_then(|state| {
203 if state.buffer.line_count().is_some() {
204 Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
205 } else {
206 None
207 }
208 });
209 snapshot.viewport = Some(ViewportInfo {
210 top_byte: active_vs.viewport.top_byte,
211 top_line,
212 left_column: active_vs.viewport.left_column,
213 width: active_vs.viewport.width,
214 height: active_vs.viewport.height,
215 });
216 } else {
217 snapshot.primary_cursor = None;
218 snapshot.all_cursors.clear();
219 snapshot.viewport = None;
220 snapshot.selected_text = None;
221 }
222
223 snapshot.clipboard = self.clipboard.get_internal().to_string();
225
226 snapshot.working_dir = self.working_dir.clone();
228 snapshot.authority_label = self.authority.display_label.clone();
229
230 snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
232
233 snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
235
236 if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
245 let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
246 self.config_cached_json = Arc::new(json);
247 self.config_snapshot_anchor = Arc::clone(&self.config);
248 }
249 snapshot.config = Arc::clone(&self.config_cached_json);
250
251 snapshot.user_config = Arc::clone(&self.user_config_raw);
255
256 snapshot.editor_mode = self.editor_mode.clone();
258
259 for (plugin_name, state_map) in &self.plugin_global_state {
262 let entry = snapshot
263 .plugin_global_states
264 .entry(plugin_name.clone())
265 .or_default();
266 for (key, value) in state_map {
267 entry.entry(key.clone()).or_insert_with(|| value.clone());
268 }
269 }
270
271 let active_split_id = self.split_manager.active_split().0 .0;
276 let split_changed = snapshot.plugin_view_states_split != active_split_id;
277 if split_changed {
278 snapshot.plugin_view_states.clear();
279 snapshot.plugin_view_states_split = active_split_id;
280 }
281
282 {
284 let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
285 snapshot
286 .plugin_view_states
287 .retain(|bid, _| open_bids.contains(bid));
288 }
289
290 if let Some(active_vs) = self
292 .split_view_states
293 .get(&self.split_manager.active_split())
294 {
295 for (buffer_id, buf_state) in &active_vs.keyed_states {
296 if !buf_state.plugin_state.is_empty() {
297 let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
298 for (key, value) in &buf_state.plugin_state {
299 entry.entry(key.clone()).or_insert_with(|| value.clone());
301 }
302 }
303 }
304 }
305 }
306 }
307
308 pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
310 match command {
311 PluginCommand::InsertText {
313 buffer_id,
314 position,
315 text,
316 } => {
317 self.handle_insert_text(buffer_id, position, text);
318 }
319 PluginCommand::DeleteRange { buffer_id, range } => {
320 self.handle_delete_range(buffer_id, range);
321 }
322 PluginCommand::InsertAtCursor { text } => {
323 self.handle_insert_at_cursor(text);
324 }
325 PluginCommand::DeleteSelection => {
326 self.handle_delete_selection();
327 }
328
329 PluginCommand::AddOverlay {
331 buffer_id,
332 namespace,
333 range,
334 options,
335 } => {
336 self.handle_add_overlay(buffer_id, namespace, range, options);
337 }
338 PluginCommand::RemoveOverlay { buffer_id, handle } => {
339 self.handle_remove_overlay(buffer_id, handle);
340 }
341 PluginCommand::ClearAllOverlays { buffer_id } => {
342 self.handle_clear_all_overlays(buffer_id);
343 }
344 PluginCommand::ClearNamespace {
345 buffer_id,
346 namespace,
347 } => {
348 self.handle_clear_namespace(buffer_id, namespace);
349 }
350 PluginCommand::ClearOverlaysInRange {
351 buffer_id,
352 start,
353 end,
354 } => {
355 self.handle_clear_overlays_in_range(buffer_id, start, end);
356 }
357
358 PluginCommand::AddVirtualText {
360 buffer_id,
361 virtual_text_id,
362 position,
363 text,
364 color,
365 use_bg,
366 before,
367 } => {
368 self.handle_add_virtual_text(
369 buffer_id,
370 virtual_text_id,
371 position,
372 text,
373 color,
374 use_bg,
375 before,
376 );
377 }
378 PluginCommand::RemoveVirtualText {
379 buffer_id,
380 virtual_text_id,
381 } => {
382 self.handle_remove_virtual_text(buffer_id, virtual_text_id);
383 }
384 PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
385 self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
386 }
387 PluginCommand::ClearVirtualTexts { buffer_id } => {
388 self.handle_clear_virtual_texts(buffer_id);
389 }
390 PluginCommand::AddVirtualLine {
391 buffer_id,
392 position,
393 text,
394 fg_color,
395 bg_color,
396 above,
397 namespace,
398 priority,
399 } => {
400 self.handle_add_virtual_line(
401 buffer_id, position, text, fg_color, bg_color, above, namespace, priority,
402 );
403 }
404 PluginCommand::ClearVirtualTextNamespace {
405 buffer_id,
406 namespace,
407 } => {
408 self.handle_clear_virtual_text_namespace(buffer_id, namespace);
409 }
410
411 PluginCommand::AddConceal {
413 buffer_id,
414 namespace,
415 start,
416 end,
417 replacement,
418 } => {
419 self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
420 }
421 PluginCommand::ClearConcealNamespace {
422 buffer_id,
423 namespace,
424 } => {
425 self.handle_clear_conceal_namespace(buffer_id, namespace);
426 }
427 PluginCommand::ClearConcealsInRange {
428 buffer_id,
429 start,
430 end,
431 } => {
432 self.handle_clear_conceals_in_range(buffer_id, start, end);
433 }
434
435 PluginCommand::AddFold {
436 buffer_id,
437 start,
438 end,
439 placeholder,
440 } => {
441 self.handle_add_fold(buffer_id, start, end, placeholder);
442 }
443 PluginCommand::ClearFolds { buffer_id } => {
444 self.handle_clear_folds(buffer_id);
445 }
446
447 PluginCommand::AddSoftBreak {
449 buffer_id,
450 namespace,
451 position,
452 indent,
453 } => {
454 self.handle_add_soft_break(buffer_id, namespace, position, indent);
455 }
456 PluginCommand::ClearSoftBreakNamespace {
457 buffer_id,
458 namespace,
459 } => {
460 self.handle_clear_soft_break_namespace(buffer_id, namespace);
461 }
462 PluginCommand::ClearSoftBreaksInRange {
463 buffer_id,
464 start,
465 end,
466 } => {
467 self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
468 }
469
470 PluginCommand::AddMenuItem {
472 menu_label,
473 item,
474 position,
475 } => {
476 self.handle_add_menu_item(menu_label, item, position);
477 }
478 PluginCommand::AddMenu { menu, position } => {
479 self.handle_add_menu(menu, position);
480 }
481 PluginCommand::RemoveMenuItem {
482 menu_label,
483 item_label,
484 } => {
485 self.handle_remove_menu_item(menu_label, item_label);
486 }
487 PluginCommand::RemoveMenu { menu_label } => {
488 self.handle_remove_menu(menu_label);
489 }
490
491 PluginCommand::FocusSplit { split_id } => {
493 self.handle_focus_split(split_id);
494 }
495 PluginCommand::SetSplitBuffer {
496 split_id,
497 buffer_id,
498 } => {
499 self.handle_set_split_buffer(split_id, buffer_id);
500 }
501 PluginCommand::SetSplitScroll { split_id, top_byte } => {
502 self.handle_set_split_scroll(split_id, top_byte);
503 }
504 PluginCommand::RequestHighlights {
505 buffer_id,
506 range,
507 request_id,
508 } => {
509 self.handle_request_highlights(buffer_id, range, request_id);
510 }
511 PluginCommand::CloseSplit { split_id } => {
512 self.handle_close_split(split_id);
513 }
514 PluginCommand::SetSplitRatio { split_id, ratio } => {
515 self.handle_set_split_ratio(split_id, ratio);
516 }
517 PluginCommand::SetSplitLabel { split_id, label } => {
518 self.split_manager.set_label(LeafId(split_id), label);
519 }
520 PluginCommand::ClearSplitLabel { split_id } => {
521 self.split_manager.clear_label(split_id);
522 }
523 PluginCommand::GetSplitByLabel { label, request_id } => {
524 let split_id = self.split_manager.find_split_by_label(&label);
525 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
526 let json = serde_json::to_string(&split_id.map(|s| s.0 .0))
527 .unwrap_or_else(|_| "null".to_string());
528 self.plugin_manager.resolve_callback(callback_id, json);
529 }
530 PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
531 self.handle_distribute_splits_evenly();
532 }
533 PluginCommand::SetBufferCursor {
534 buffer_id,
535 position,
536 } => {
537 self.handle_set_buffer_cursor(buffer_id, position);
538 }
539 PluginCommand::SetBufferShowCursors { buffer_id, show } => {
540 if let Some(state) = self.buffers.get_mut(&buffer_id) {
541 state.show_cursors = show;
542 } else {
543 tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
544 }
545 }
546
547 PluginCommand::SetLayoutHints {
549 buffer_id,
550 split_id,
551 range: _,
552 hints,
553 } => {
554 self.handle_set_layout_hints(buffer_id, split_id, hints);
555 }
556 PluginCommand::SetLineNumbers { buffer_id, enabled } => {
557 self.handle_set_line_numbers(buffer_id, enabled);
558 }
559 PluginCommand::SetViewMode { buffer_id, mode } => {
560 self.handle_set_view_mode(buffer_id, &mode);
561 }
562 PluginCommand::SetLineWrap {
563 buffer_id,
564 split_id,
565 enabled,
566 } => {
567 self.handle_set_line_wrap(buffer_id, split_id, enabled);
568 }
569 PluginCommand::SubmitViewTransform {
570 buffer_id,
571 split_id,
572 payload,
573 } => {
574 self.handle_submit_view_transform(buffer_id, split_id, payload);
575 }
576 PluginCommand::ClearViewTransform {
577 buffer_id: _,
578 split_id,
579 } => {
580 self.handle_clear_view_transform(split_id);
581 }
582 PluginCommand::SetViewState {
583 buffer_id,
584 key,
585 value,
586 } => {
587 self.handle_set_view_state(buffer_id, key, value);
588 }
589 PluginCommand::SetGlobalState {
590 plugin_name,
591 key,
592 value,
593 } => {
594 self.handle_set_global_state(plugin_name, key, value);
595 }
596 PluginCommand::RefreshLines { buffer_id } => {
597 self.handle_refresh_lines(buffer_id);
598 }
599 PluginCommand::RefreshAllLines => {
600 self.handle_refresh_all_lines();
601 }
602 PluginCommand::HookCompleted { .. } => {
603 }
605 PluginCommand::SetLineIndicator {
606 buffer_id,
607 line,
608 namespace,
609 symbol,
610 color,
611 priority,
612 } => {
613 self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
614 }
615 PluginCommand::SetLineIndicators {
616 buffer_id,
617 lines,
618 namespace,
619 symbol,
620 color,
621 priority,
622 } => {
623 self.handle_set_line_indicators(
624 buffer_id, lines, namespace, symbol, color, priority,
625 );
626 }
627 PluginCommand::ClearLineIndicators {
628 buffer_id,
629 namespace,
630 } => {
631 self.handle_clear_line_indicators(buffer_id, namespace);
632 }
633 PluginCommand::SetFileExplorerDecorations {
634 namespace,
635 decorations,
636 } => {
637 self.handle_set_file_explorer_decorations(namespace, decorations);
638 }
639 PluginCommand::ClearFileExplorerDecorations { namespace } => {
640 self.handle_clear_file_explorer_decorations(&namespace);
641 }
642
643 PluginCommand::SetStatus { message } => {
645 self.handle_set_status(message);
646 }
647 PluginCommand::ApplyTheme { theme_name } => {
648 self.apply_theme(&theme_name);
649 }
650 PluginCommand::OverrideThemeColors { overrides } => {
651 let pairs = overrides
652 .into_iter()
653 .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
654 let applied = self.theme.override_colors(pairs);
655 if applied > 0 {
656 self.reapply_all_overlays();
660 }
661 }
662 PluginCommand::ReloadConfig => {
663 self.reload_config();
664 }
665 PluginCommand::SetSetting { path, value, .. } => {
666 self.handle_set_setting(path, value);
667 }
668 PluginCommand::ReloadThemes { apply_theme } => {
669 self.reload_themes();
670 if let Some(theme_name) = apply_theme {
671 self.apply_theme(&theme_name);
672 }
673 }
674 PluginCommand::RegisterGrammar {
675 language,
676 grammar_path,
677 extensions,
678 } => {
679 self.handle_register_grammar(language, grammar_path, extensions);
680 }
681 PluginCommand::RegisterLanguageConfig { language, config } => {
682 self.handle_register_language_config(language, config);
683 }
684 PluginCommand::RegisterLspServer { language, config } => {
685 self.handle_register_lsp_server(language, config);
686 }
687 PluginCommand::ReloadGrammars { callback_id } => {
688 self.handle_reload_grammars(callback_id);
689 }
690 PluginCommand::StartPrompt { label, prompt_type } => {
691 self.handle_start_prompt(label, prompt_type);
692 }
693 PluginCommand::StartPromptWithInitial {
694 label,
695 prompt_type,
696 initial_value,
697 } => {
698 self.handle_start_prompt_with_initial(label, prompt_type, initial_value);
699 }
700 PluginCommand::StartPromptAsync {
701 label,
702 initial_value,
703 callback_id,
704 } => {
705 self.handle_start_prompt_async(label, initial_value, callback_id);
706 }
707 PluginCommand::SetPromptSuggestions { suggestions } => {
708 self.handle_set_prompt_suggestions(suggestions);
709 }
710 PluginCommand::SetPromptInputSync { sync } => {
711 if let Some(prompt) = &mut self.prompt {
712 prompt.sync_input_on_navigate = sync;
713 }
714 }
715
716 PluginCommand::RegisterCommand { command } => {
718 self.handle_register_command(command);
719 }
720 PluginCommand::UnregisterCommand { name } => {
721 self.handle_unregister_command(name);
722 }
723 PluginCommand::DefineMode {
724 name,
725 bindings,
726 read_only,
727 allow_text_input,
728 inherit_normal_bindings,
729 plugin_name,
730 } => {
731 self.handle_define_mode(
732 name,
733 bindings,
734 read_only,
735 allow_text_input,
736 inherit_normal_bindings,
737 plugin_name,
738 );
739 }
740
741 PluginCommand::OpenFileInBackground { path } => {
743 self.handle_open_file_in_background(path);
744 }
745 PluginCommand::OpenFileAtLocation { path, line, column } => {
746 return self.handle_open_file_at_location(path, line, column);
747 }
748 PluginCommand::OpenFileInSplit {
749 split_id,
750 path,
751 line,
752 column,
753 } => {
754 return self.handle_open_file_in_split(split_id, path, line, column);
755 }
756 PluginCommand::ShowBuffer { buffer_id } => {
757 self.handle_show_buffer(buffer_id);
758 }
759 PluginCommand::CloseBuffer { buffer_id } => {
760 self.handle_close_buffer(buffer_id);
761 }
762
763 PluginCommand::SendLspRequest {
765 language,
766 method,
767 params,
768 request_id,
769 } => {
770 self.handle_send_lsp_request(language, method, params, request_id);
771 }
772
773 PluginCommand::SetClipboard { text } => {
775 self.handle_set_clipboard(text);
776 }
777
778 PluginCommand::SpawnProcess {
780 command,
781 args,
782 cwd,
783 callback_id,
784 } => {
785 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
788 let effective_cwd = cwd.or_else(|| {
789 std::env::current_dir()
790 .map(|p| p.to_string_lossy().to_string())
791 .ok()
792 });
793 let sender = bridge.sender();
794 let spawner = self.authority.process_spawner.clone();
795
796 runtime.spawn(async move {
797 #[allow(clippy::let_underscore_must_use)]
799 match spawner.spawn(command, args, effective_cwd).await {
800 Ok(result) => {
801 let _ = sender.send(AsyncMessage::PluginProcessOutput {
802 process_id: callback_id.as_u64(),
803 stdout: result.stdout,
804 stderr: result.stderr,
805 exit_code: result.exit_code,
806 });
807 }
808 Err(e) => {
809 let _ = sender.send(AsyncMessage::PluginProcessOutput {
810 process_id: callback_id.as_u64(),
811 stdout: String::new(),
812 stderr: e.to_string(),
813 exit_code: -1,
814 });
815 }
816 }
817 });
818 } else {
819 self.plugin_manager
821 .reject_callback(callback_id, "Async runtime not available".to_string());
822 }
823 }
824
825 PluginCommand::SpawnHostProcess {
826 command,
827 args,
828 cwd,
829 callback_id,
830 } => {
831 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
846 use tokio::io::{AsyncReadExt, BufReader};
847 use tokio::process::Command as TokioCommand;
848
849 let effective_cwd = cwd.or_else(|| {
850 std::env::current_dir()
851 .map(|p| p.to_string_lossy().to_string())
852 .ok()
853 });
854 let sender = bridge.sender();
855 let process_id = callback_id.as_u64();
856
857 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
858 self.host_process_handles.insert(process_id, kill_tx);
859
860 runtime.spawn(async move {
861 let mut cmd = TokioCommand::new(&command);
862 cmd.args(&args);
863 cmd.stdout(std::process::Stdio::piped());
864 cmd.stderr(std::process::Stdio::piped());
865 if let Some(ref dir) = effective_cwd {
866 cmd.current_dir(dir);
867 }
868 let mut child = match cmd.spawn() {
869 Ok(c) => c,
870 Err(e) => {
871 #[allow(clippy::let_underscore_must_use)]
872 let _ = sender.send(AsyncMessage::PluginProcessOutput {
873 process_id,
874 stdout: String::new(),
875 stderr: e.to_string(),
876 exit_code: -1,
877 });
878 return;
879 }
880 };
881
882 let stdout_pipe = child.stdout.take();
888 let stderr_pipe = child.stderr.take();
889
890 let stdout_fut = async {
891 let mut buf = String::new();
892 if let Some(s) = stdout_pipe {
893 #[allow(clippy::let_underscore_must_use)]
894 let _ = BufReader::new(s).read_to_string(&mut buf).await;
895 }
896 buf
897 };
898 let stderr_fut = async {
899 let mut buf = String::new();
900 if let Some(s) = stderr_pipe {
901 #[allow(clippy::let_underscore_must_use)]
902 let _ = BufReader::new(s).read_to_string(&mut buf).await;
903 }
904 buf
905 };
906 let wait_fut = async {
907 tokio::select! {
908 status = child.wait() => {
909 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
910 }
911 _ = &mut kill_rx => {
912 #[allow(clippy::let_underscore_must_use)]
916 let _ = child.start_kill();
917 child
918 .wait()
919 .await
920 .map(|s| s.code().unwrap_or(-1))
921 .unwrap_or(-1)
922 }
923 }
924 };
925 let (stdout, stderr, exit_code) =
926 tokio::join!(stdout_fut, stderr_fut, wait_fut);
927
928 #[allow(clippy::let_underscore_must_use)]
929 let _ = sender.send(AsyncMessage::PluginProcessOutput {
930 process_id,
931 stdout,
932 stderr,
933 exit_code,
934 });
935 });
936 } else {
937 self.plugin_manager
938 .reject_callback(callback_id, "Async runtime not available".to_string());
939 }
940 }
941
942 PluginCommand::KillHostProcess { process_id } => {
943 if let Some(tx) = self.host_process_handles.remove(&process_id) {
949 #[allow(clippy::let_underscore_must_use)]
950 let _ = tx.send(());
951 tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
952 } else {
953 tracing::debug!(
954 "KillHostProcess: unknown process_id={} (already exited?)",
955 process_id
956 );
957 }
958 }
959
960 PluginCommand::SetAuthority { payload } => {
961 match serde_json::from_value::<crate::services::authority::AuthorityPayload>(
967 payload,
968 ) {
969 Ok(parsed) => {
970 match crate::services::authority::Authority::from_plugin_payload(parsed) {
971 Ok(auth) => {
972 tracing::info!("Plugin installed new authority");
973 self.install_authority(auth);
974 }
975 Err(e) => {
976 tracing::warn!("setAuthority: invalid payload: {}", e);
977 self.set_status_message(format!("setAuthority rejected: {}", e));
978 }
979 }
980 }
981 Err(e) => {
982 tracing::warn!("setAuthority: failed to parse payload: {}", e);
983 self.set_status_message(format!("setAuthority rejected: {}", e));
984 }
985 }
986 }
987
988 PluginCommand::ClearAuthority => {
989 tracing::info!("Plugin cleared authority; restoring local");
990 self.clear_authority();
991 }
992
993 PluginCommand::SetRemoteIndicatorState { state } => {
994 match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(
998 state,
999 ) {
1000 Ok(over) => {
1001 self.remote_indicator_override = Some(over);
1002 }
1003 Err(e) => {
1004 tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
1005 self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
1006 }
1007 }
1008 }
1009
1010 PluginCommand::ClearRemoteIndicatorState => {
1011 self.remote_indicator_override = None;
1012 }
1013
1014 PluginCommand::SpawnProcessWait {
1015 process_id,
1016 callback_id,
1017 } => {
1018 tracing::warn!(
1021 "SpawnProcessWait not fully implemented - process_id={}",
1022 process_id
1023 );
1024 self.plugin_manager.reject_callback(
1025 callback_id,
1026 format!(
1027 "SpawnProcessWait not yet fully implemented for process_id={}",
1028 process_id
1029 ),
1030 );
1031 }
1032
1033 PluginCommand::Delay {
1034 callback_id,
1035 duration_ms,
1036 } => {
1037 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1039 let sender = bridge.sender();
1040 let callback_id_u64 = callback_id.as_u64();
1041 runtime.spawn(async move {
1042 tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
1043 #[allow(clippy::let_underscore_must_use)]
1045 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1046 fresh_core::api::PluginAsyncMessage::DelayComplete {
1047 callback_id: callback_id_u64,
1048 },
1049 ));
1050 });
1051 } else {
1052 std::thread::sleep(std::time::Duration::from_millis(duration_ms));
1054 self.plugin_manager
1055 .resolve_callback(callback_id, "null".to_string());
1056 }
1057 }
1058
1059 PluginCommand::SpawnBackgroundProcess {
1060 process_id,
1061 command,
1062 args,
1063 cwd,
1064 callback_id,
1065 } => {
1066 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1068 use tokio::io::{AsyncBufReadExt, BufReader};
1069 use tokio::process::Command as TokioCommand;
1070
1071 let effective_cwd = cwd.unwrap_or_else(|| {
1072 std::env::current_dir()
1073 .map(|p| p.to_string_lossy().to_string())
1074 .unwrap_or_else(|_| ".".to_string())
1075 });
1076
1077 let sender = bridge.sender();
1078 let sender_stdout = sender.clone();
1079 let sender_stderr = sender.clone();
1080 let callback_id_u64 = callback_id.as_u64();
1081
1082 #[allow(clippy::let_underscore_must_use)]
1084 let handle = runtime.spawn(async move {
1085 let mut child = match TokioCommand::new(&command)
1086 .args(&args)
1087 .current_dir(&effective_cwd)
1088 .stdout(std::process::Stdio::piped())
1089 .stderr(std::process::Stdio::piped())
1090 .spawn()
1091 {
1092 Ok(child) => child,
1093 Err(e) => {
1094 let _ = sender.send(
1095 crate::services::async_bridge::AsyncMessage::Plugin(
1096 fresh_core::api::PluginAsyncMessage::ProcessExit {
1097 process_id,
1098 callback_id: callback_id_u64,
1099 exit_code: -1,
1100 },
1101 ),
1102 );
1103 tracing::error!("Failed to spawn background process: {}", e);
1104 return;
1105 }
1106 };
1107
1108 let stdout = child.stdout.take();
1110 let stderr = child.stderr.take();
1111 let pid = process_id;
1112
1113 if let Some(stdout) = stdout {
1115 let sender = sender_stdout;
1116 tokio::spawn(async move {
1117 let reader = BufReader::new(stdout);
1118 let mut lines = reader.lines();
1119 while let Ok(Some(line)) = lines.next_line().await {
1120 let _ = sender.send(
1121 crate::services::async_bridge::AsyncMessage::Plugin(
1122 fresh_core::api::PluginAsyncMessage::ProcessStdout {
1123 process_id: pid,
1124 data: line + "\n",
1125 },
1126 ),
1127 );
1128 }
1129 });
1130 }
1131
1132 if let Some(stderr) = stderr {
1134 let sender = sender_stderr;
1135 tokio::spawn(async move {
1136 let reader = BufReader::new(stderr);
1137 let mut lines = reader.lines();
1138 while let Ok(Some(line)) = lines.next_line().await {
1139 let _ = sender.send(
1140 crate::services::async_bridge::AsyncMessage::Plugin(
1141 fresh_core::api::PluginAsyncMessage::ProcessStderr {
1142 process_id: pid,
1143 data: line + "\n",
1144 },
1145 ),
1146 );
1147 }
1148 });
1149 }
1150
1151 let exit_code = match child.wait().await {
1153 Ok(status) => status.code().unwrap_or(-1),
1154 Err(_) => -1,
1155 };
1156
1157 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1158 fresh_core::api::PluginAsyncMessage::ProcessExit {
1159 process_id,
1160 callback_id: callback_id_u64,
1161 exit_code,
1162 },
1163 ));
1164 });
1165
1166 self.background_process_handles
1168 .insert(process_id, handle.abort_handle());
1169 } else {
1170 self.plugin_manager
1172 .reject_callback(callback_id, "Async runtime not available".to_string());
1173 }
1174 }
1175
1176 PluginCommand::KillBackgroundProcess { process_id } => {
1177 if let Some(handle) = self.background_process_handles.remove(&process_id) {
1178 handle.abort();
1179 tracing::debug!("Killed background process {}", process_id);
1180 }
1181 }
1182
1183 PluginCommand::CreateVirtualBuffer {
1185 name,
1186 mode,
1187 read_only,
1188 } => {
1189 let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1190 tracing::info!(
1191 "Created virtual buffer '{}' with mode '{}' (id={:?})",
1192 name,
1193 mode,
1194 buffer_id
1195 );
1196 }
1198 PluginCommand::CreateVirtualBufferWithContent {
1199 name,
1200 mode,
1201 read_only,
1202 entries,
1203 show_line_numbers,
1204 show_cursors,
1205 editing_disabled,
1206 hidden_from_tabs,
1207 request_id,
1208 } => {
1209 let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1210 tracing::info!(
1211 "Created virtual buffer '{}' with mode '{}' (id={:?})",
1212 name,
1213 mode,
1214 buffer_id
1215 );
1216
1217 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1224 state.margins.configure_for_line_numbers(show_line_numbers);
1225 state.show_cursors = show_cursors;
1226 state.editing_disabled = editing_disabled;
1227 tracing::debug!(
1228 "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
1229 buffer_id,
1230 show_line_numbers,
1231 show_cursors,
1232 editing_disabled
1233 );
1234 }
1235 let active_split = self.split_manager.active_split();
1236 if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1237 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
1238 }
1239
1240 if hidden_from_tabs {
1242 if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
1243 meta.hidden_from_tabs = true;
1244 }
1245 }
1246
1247 match self.set_virtual_buffer_content(buffer_id, entries) {
1249 Ok(()) => {
1250 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
1251 self.set_active_buffer(buffer_id);
1253 tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
1254
1255 if let Some(req_id) = request_id {
1257 tracing::info!(
1258 "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
1259 req_id,
1260 buffer_id
1261 );
1262 let result = fresh_core::api::VirtualBufferResult {
1264 buffer_id: buffer_id.0 as u64,
1265 split_id: None,
1266 };
1267 self.plugin_manager.resolve_callback(
1268 fresh_core::api::JsCallbackId::from(req_id),
1269 serde_json::to_string(&result).unwrap_or_default(),
1270 );
1271 tracing::info!("CreateVirtualBufferWithContent: resolve_callback sent for request_id={}", req_id);
1272 }
1273 }
1274 Err(e) => {
1275 tracing::error!("Failed to set virtual buffer content: {}", e);
1276 }
1277 }
1278 }
1279 PluginCommand::CreateVirtualBufferInSplit {
1280 name,
1281 mode,
1282 read_only,
1283 entries,
1284 ratio,
1285 direction,
1286 panel_id,
1287 show_line_numbers,
1288 show_cursors,
1289 editing_disabled,
1290 line_wrap,
1291 before,
1292 request_id,
1293 } => {
1294 if let Some(pid) = &panel_id {
1296 if let Some(&existing_buffer_id) = self.panel_ids.get(pid) {
1297 if self.buffers.contains_key(&existing_buffer_id) {
1299 if let Err(e) =
1301 self.set_virtual_buffer_content(existing_buffer_id, entries)
1302 {
1303 tracing::error!("Failed to update panel content: {}", e);
1304 } else {
1305 tracing::info!("Updated existing panel '{}' content", pid);
1306 }
1307
1308 let splits = self.split_manager.splits_for_buffer(existing_buffer_id);
1310 if let Some(&split_id) = splits.first() {
1311 self.split_manager.set_active_split(split_id);
1312 self.set_pane_buffer(split_id, existing_buffer_id);
1315 tracing::debug!(
1316 "Focused split {:?} containing panel buffer",
1317 split_id
1318 );
1319 }
1320
1321 if let Some(req_id) = request_id {
1323 let result = fresh_core::api::VirtualBufferResult {
1324 buffer_id: existing_buffer_id.0 as u64,
1325 split_id: splits.first().map(|s| s.0 .0 as u64),
1326 };
1327 self.plugin_manager.resolve_callback(
1328 fresh_core::api::JsCallbackId::from(req_id),
1329 serde_json::to_string(&result).unwrap_or_default(),
1330 );
1331 }
1332 return Ok(());
1333 } else {
1334 tracing::warn!(
1336 "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
1337 pid,
1338 existing_buffer_id
1339 );
1340 self.panel_ids.remove(pid);
1341 }
1343 }
1344 }
1345
1346 let source_split_before_create = self.split_manager.active_split();
1352
1353 let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1355 tracing::info!(
1356 "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
1357 name,
1358 mode,
1359 buffer_id
1360 );
1361
1362 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1364 state.margins.configure_for_line_numbers(show_line_numbers);
1365 state.show_cursors = show_cursors;
1366 state.editing_disabled = editing_disabled;
1367 tracing::debug!(
1368 "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
1369 buffer_id,
1370 show_line_numbers,
1371 show_cursors,
1372 editing_disabled
1373 );
1374 }
1375
1376 if let Some(pid) = panel_id {
1378 self.panel_ids.insert(pid, buffer_id);
1379 }
1380
1381 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1383 tracing::error!("Failed to set virtual buffer content: {}", e);
1384 return Ok(());
1385 }
1386
1387 let split_dir = match direction.as_deref() {
1389 Some("vertical") => crate::model::event::SplitDirection::Vertical,
1390 _ => crate::model::event::SplitDirection::Horizontal,
1391 };
1392
1393 let created_split_id = match self
1395 .split_manager
1396 .split_active_positioned(split_dir, buffer_id, ratio, before)
1397 {
1398 Ok(new_split_id) => {
1399 if new_split_id != source_split_before_create {
1405 if let Some(source_view_state) =
1406 self.split_view_states.get_mut(&source_split_before_create)
1407 {
1408 source_view_state.remove_buffer(buffer_id);
1409 }
1410 }
1411 let mut view_state = SplitViewState::with_buffer(
1413 self.terminal_width,
1414 self.terminal_height,
1415 buffer_id,
1416 );
1417 view_state.apply_config_defaults(
1418 self.config.editor.line_numbers,
1419 self.config.editor.highlight_current_line,
1420 line_wrap
1421 .unwrap_or_else(|| self.resolve_line_wrap_for_buffer(buffer_id)),
1422 self.config.editor.wrap_indent,
1423 self.resolve_wrap_column_for_buffer(buffer_id),
1424 self.config.editor.rulers.clone(),
1425 );
1426 view_state.ensure_buffer_state(buffer_id).show_line_numbers =
1428 show_line_numbers;
1429 self.split_view_states.insert(new_split_id, view_state);
1430
1431 self.split_manager.set_active_split(new_split_id);
1433 tracing::info!(
1436 "Created {:?} split with virtual buffer {:?}",
1437 split_dir,
1438 buffer_id
1439 );
1440 Some(new_split_id)
1441 }
1442 Err(e) => {
1443 tracing::error!("Failed to create split: {}", e);
1444 self.set_active_buffer(buffer_id);
1446 None
1447 }
1448 };
1449
1450 if let Some(req_id) = request_id {
1453 tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
1454 let result = fresh_core::api::VirtualBufferResult {
1455 buffer_id: buffer_id.0 as u64,
1456 split_id: created_split_id.map(|s| s.0 .0 as u64),
1457 };
1458 self.plugin_manager.resolve_callback(
1459 fresh_core::api::JsCallbackId::from(req_id),
1460 serde_json::to_string(&result).unwrap_or_default(),
1461 );
1462 }
1463 }
1464 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1465 match self.set_virtual_buffer_content(buffer_id, entries) {
1466 Ok(()) => {
1467 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
1468 }
1469 Err(e) => {
1470 tracing::error!("Failed to set virtual buffer content: {}", e);
1471 }
1472 }
1473 }
1474 PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1475 if let Some(state) = self.buffers.get(&buffer_id) {
1477 let cursor_pos = self
1478 .split_view_states
1479 .values()
1480 .find_map(|vs| vs.buffer_state(buffer_id))
1481 .map(|bs| bs.cursors.primary().position)
1482 .unwrap_or(0);
1483 let properties = state.text_properties.get_at(cursor_pos);
1484 tracing::debug!(
1485 "Text properties at cursor in {:?}: {} properties found",
1486 buffer_id,
1487 properties.len()
1488 );
1489 }
1491 }
1492 PluginCommand::CreateVirtualBufferInExistingSplit {
1493 name,
1494 mode,
1495 read_only,
1496 entries,
1497 split_id,
1498 show_line_numbers,
1499 show_cursors,
1500 editing_disabled,
1501 line_wrap,
1502 request_id,
1503 } => {
1504 let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
1506 tracing::info!(
1507 "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
1508 name,
1509 mode,
1510 split_id,
1511 buffer_id
1512 );
1513
1514 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1516 state.margins.configure_for_line_numbers(show_line_numbers);
1517 state.show_cursors = show_cursors;
1518 state.editing_disabled = editing_disabled;
1519 }
1520
1521 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1523 tracing::error!("Failed to set virtual buffer content: {}", e);
1524 return Ok(());
1525 }
1526
1527 let leaf_id = LeafId(split_id);
1530 self.split_manager.set_active_split(leaf_id);
1531 self.set_pane_buffer(leaf_id, buffer_id);
1532
1533 if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
1539 view_state.switch_buffer(buffer_id);
1540 view_state.add_buffer(buffer_id);
1541 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
1542
1543 if let Some(wrap) = line_wrap {
1545 view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
1546 }
1547 }
1548
1549 tracing::info!(
1550 "Displayed virtual buffer {:?} in split {:?}",
1551 buffer_id,
1552 split_id
1553 );
1554
1555 if let Some(req_id) = request_id {
1557 let result = fresh_core::api::VirtualBufferResult {
1558 buffer_id: buffer_id.0 as u64,
1559 split_id: Some(split_id.0 as u64),
1560 };
1561 self.plugin_manager.resolve_callback(
1562 fresh_core::api::JsCallbackId::from(req_id),
1563 serde_json::to_string(&result).unwrap_or_default(),
1564 );
1565 }
1566 }
1567
1568 PluginCommand::SetContext { name, active } => {
1570 if active {
1571 self.active_custom_contexts.insert(name.clone());
1572 tracing::debug!("Set custom context: {}", name);
1573 } else {
1574 self.active_custom_contexts.remove(&name);
1575 tracing::debug!("Unset custom context: {}", name);
1576 }
1577 }
1578
1579 PluginCommand::SetReviewDiffHunks { hunks } => {
1581 self.review_hunks = hunks;
1582 tracing::debug!("Set {} review hunks", self.review_hunks.len());
1583 }
1584
1585 PluginCommand::ExecuteAction { action_name } => {
1587 self.handle_execute_action(action_name);
1588 }
1589 PluginCommand::ExecuteActions { actions } => {
1590 self.handle_execute_actions(actions);
1591 }
1592 PluginCommand::GetBufferText {
1593 buffer_id,
1594 start,
1595 end,
1596 request_id,
1597 } => {
1598 self.handle_get_buffer_text(buffer_id, start, end, request_id);
1599 }
1600 PluginCommand::GetLineStartPosition {
1601 buffer_id,
1602 line,
1603 request_id,
1604 } => {
1605 self.handle_get_line_start_position(buffer_id, line, request_id);
1606 }
1607 PluginCommand::GetLineEndPosition {
1608 buffer_id,
1609 line,
1610 request_id,
1611 } => {
1612 self.handle_get_line_end_position(buffer_id, line, request_id);
1613 }
1614 PluginCommand::GetBufferLineCount {
1615 buffer_id,
1616 request_id,
1617 } => {
1618 self.handle_get_buffer_line_count(buffer_id, request_id);
1619 }
1620 PluginCommand::ScrollToLineCenter {
1621 split_id,
1622 buffer_id,
1623 line,
1624 } => {
1625 self.handle_scroll_to_line_center(split_id, buffer_id, line);
1626 }
1627 PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1628 self.handle_scroll_buffer_to_line(buffer_id, line);
1629 }
1630 PluginCommand::SetEditorMode { mode } => {
1631 self.handle_set_editor_mode(mode);
1632 }
1633
1634 PluginCommand::ShowActionPopup {
1636 popup_id,
1637 title,
1638 message,
1639 actions,
1640 } => {
1641 tracing::info!(
1642 "Action popup requested: id={}, title={}, actions={}",
1643 popup_id,
1644 title,
1645 actions.len()
1646 );
1647
1648 let items: Vec<crate::model::event::PopupListItemData> = actions
1650 .iter()
1651 .map(|action| crate::model::event::PopupListItemData {
1652 text: action.label.clone(),
1653 detail: None,
1654 icon: None,
1655 data: Some(action.id.clone()),
1656 })
1657 .collect();
1658
1659 drop(actions);
1664
1665 let popup_data = crate::model::event::PopupData {
1667 kind: crate::model::event::PopupKindHint::List,
1668 title: Some(title),
1669 description: Some(message),
1670 transient: false,
1671 content: crate::model::event::PopupContentData::List { items, selected: 0 },
1672 position: crate::model::event::PopupPositionData::BottomRight,
1673 width: 60,
1674 max_height: 15,
1675 bordered: true,
1676 };
1677
1678 let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
1688 popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
1689 popup_id: popup_id.clone(),
1690 };
1691 self.global_popups.show(popup_obj);
1692 tracing::info!(
1693 "Action popup shown: id={}, stack_depth={}",
1694 popup_id,
1695 self.global_popups.all().len()
1696 );
1697 }
1698
1699 PluginCommand::DisableLspForLanguage { language } => {
1700 tracing::info!("Disabling LSP for language: {}", language);
1701
1702 if let Some(ref mut lsp) = self.lsp {
1704 lsp.shutdown_server(&language);
1705 tracing::info!("Stopped LSP server for {}", language);
1706 }
1707
1708 if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
1710 for c in lsp_configs.as_mut_slice() {
1711 c.enabled = false;
1712 c.auto_start = false;
1713 }
1714 tracing::info!("Disabled LSP config for {}", language);
1715 }
1716
1717 if let Err(e) = self.save_config() {
1719 tracing::error!("Failed to save config: {}", e);
1720 self.status_message = Some(format!(
1721 "LSP disabled for {} (config save failed)",
1722 language
1723 ));
1724 } else {
1725 self.status_message = Some(format!("LSP disabled for {}", language));
1726 }
1727
1728 self.warning_domains.lsp.clear();
1730 }
1731
1732 PluginCommand::RestartLspForLanguage { language } => {
1733 tracing::info!("Plugin restarting LSP for language: {}", language);
1734
1735 let file_path = self
1736 .buffer_metadata
1737 .get(&self.active_buffer())
1738 .and_then(|meta| meta.file_path().cloned());
1739 let success = if let Some(ref mut lsp) = self.lsp {
1740 let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
1741 self.status_message = Some(msg);
1742 ok
1743 } else {
1744 self.status_message = Some("No LSP manager available".to_string());
1745 false
1746 };
1747
1748 if success {
1749 self.reopen_buffers_for_language(&language);
1750 }
1751 }
1752
1753 PluginCommand::SetLspRootUri { language, uri } => {
1754 tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
1755
1756 match uri.parse::<lsp_types::Uri>() {
1758 Ok(parsed_uri) => {
1759 if let Some(ref mut lsp) = self.lsp {
1760 let restarted = lsp.set_language_root_uri(&language, parsed_uri);
1761 if restarted {
1762 self.status_message = Some(format!(
1763 "LSP root updated for {} (restarting server)",
1764 language
1765 ));
1766 } else {
1767 self.status_message =
1768 Some(format!("LSP root set for {}", language));
1769 }
1770 }
1771 }
1772 Err(e) => {
1773 tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
1774 self.status_message = Some(format!("Invalid LSP root URI: {}", e));
1775 }
1776 }
1777 }
1778
1779 PluginCommand::CreateScrollSyncGroup {
1781 group_id,
1782 left_split,
1783 right_split,
1784 } => {
1785 let success = self.scroll_sync_manager.create_group_with_id(
1786 group_id,
1787 left_split,
1788 right_split,
1789 );
1790 if success {
1791 tracing::debug!(
1792 "Created scroll sync group {} for splits {:?} and {:?}",
1793 group_id,
1794 left_split,
1795 right_split
1796 );
1797 } else {
1798 tracing::warn!(
1799 "Failed to create scroll sync group {} (ID already exists)",
1800 group_id
1801 );
1802 }
1803 }
1804 PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1805 use crate::view::scroll_sync::SyncAnchor;
1806 let anchor_count = anchors.len();
1807 let sync_anchors: Vec<SyncAnchor> = anchors
1808 .into_iter()
1809 .map(|(left_line, right_line)| SyncAnchor {
1810 left_line,
1811 right_line,
1812 })
1813 .collect();
1814 self.scroll_sync_manager.set_anchors(group_id, sync_anchors);
1815 tracing::debug!(
1816 "Set {} anchors for scroll sync group {}",
1817 anchor_count,
1818 group_id
1819 );
1820 }
1821 PluginCommand::RemoveScrollSyncGroup { group_id } => {
1822 if self.scroll_sync_manager.remove_group(group_id) {
1823 tracing::debug!("Removed scroll sync group {}", group_id);
1824 } else {
1825 tracing::warn!("Scroll sync group {} not found", group_id);
1826 }
1827 }
1828
1829 PluginCommand::CreateCompositeBuffer {
1831 name,
1832 mode,
1833 layout,
1834 sources,
1835 hunks,
1836 initial_focus_hunk,
1837 request_id,
1838 } => {
1839 self.handle_create_composite_buffer(
1840 name,
1841 mode,
1842 layout,
1843 sources,
1844 hunks,
1845 initial_focus_hunk,
1846 request_id,
1847 );
1848 }
1849 PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1850 self.handle_update_composite_alignment(buffer_id, hunks);
1851 }
1852 PluginCommand::CloseCompositeBuffer { buffer_id } => {
1853 self.close_composite_buffer(buffer_id);
1854 }
1855 PluginCommand::FlushLayout => {
1856 self.flush_layout();
1857 }
1858 PluginCommand::CompositeNextHunk { buffer_id } => {
1859 let split_id = self.split_manager.active_split();
1860 self.composite_next_hunk(split_id, buffer_id);
1861 }
1862 PluginCommand::CompositePrevHunk { buffer_id } => {
1863 let split_id = self.split_manager.active_split();
1864 self.composite_prev_hunk(split_id, buffer_id);
1865 }
1866
1867 PluginCommand::CreateBufferGroup {
1869 name,
1870 mode,
1871 layout_json,
1872 request_id,
1873 } => match self.create_buffer_group(name, mode, layout_json) {
1874 Ok(result) => {
1875 if let Some(req_id) = request_id {
1876 let json = serde_json::to_string(&result).unwrap_or_default();
1877 self.plugin_manager
1878 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
1879 }
1880 }
1881 Err(e) => {
1882 tracing::error!("Failed to create buffer group: {}", e);
1883 }
1884 },
1885 PluginCommand::SetPanelContent {
1886 group_id,
1887 panel_name,
1888 entries,
1889 } => {
1890 self.set_panel_content(group_id, panel_name, entries);
1891 }
1892 PluginCommand::CloseBufferGroup { group_id } => {
1893 self.close_buffer_group(group_id);
1894 }
1895 PluginCommand::FocusPanel {
1896 group_id,
1897 panel_name,
1898 } => {
1899 self.focus_panel(group_id, panel_name);
1900 }
1901
1902 PluginCommand::SaveBufferToPath { buffer_id, path } => {
1904 self.handle_save_buffer_to_path(buffer_id, path);
1905 }
1906
1907 #[cfg(feature = "plugins")]
1909 PluginCommand::LoadPlugin { path, callback_id } => {
1910 self.handle_load_plugin(path, callback_id);
1911 }
1912 #[cfg(feature = "plugins")]
1913 PluginCommand::UnloadPlugin { name, callback_id } => {
1914 self.handle_unload_plugin(name, callback_id);
1915 }
1916 #[cfg(feature = "plugins")]
1917 PluginCommand::ReloadPlugin { name, callback_id } => {
1918 self.handle_reload_plugin(name, callback_id);
1919 }
1920 #[cfg(feature = "plugins")]
1921 PluginCommand::ListPlugins { callback_id } => {
1922 self.handle_list_plugins(callback_id);
1923 }
1924 #[cfg(not(feature = "plugins"))]
1926 PluginCommand::LoadPlugin { .. }
1927 | PluginCommand::UnloadPlugin { .. }
1928 | PluginCommand::ReloadPlugin { .. }
1929 | PluginCommand::ListPlugins { .. } => {
1930 tracing::warn!("Plugin management commands require the 'plugins' feature");
1931 }
1932
1933 PluginCommand::CreateTerminal {
1935 cwd,
1936 direction,
1937 ratio,
1938 focus,
1939 persistent,
1940 request_id,
1941 } => {
1942 let (cols, rows) = self.get_terminal_dimensions();
1943
1944 if let Some(ref bridge) = self.async_bridge {
1946 self.terminal_manager.set_async_bridge(bridge.clone());
1947 }
1948
1949 let working_dir = cwd
1951 .map(std::path::PathBuf::from)
1952 .unwrap_or_else(|| self.working_dir.clone());
1953
1954 let terminal_root = self.dir_context.terminal_dir_for(&working_dir);
1956 if let Err(e) = self.authority.filesystem.create_dir_all(&terminal_root) {
1957 tracing::warn!("Failed to create terminal directory: {}", e);
1958 }
1959 let predicted_terminal_id = self.terminal_manager.next_terminal_id();
1960 let name_stem = if persistent {
1967 format!("fresh-terminal-{}", predicted_terminal_id.0)
1968 } else {
1969 let nanos = std::time::SystemTime::now()
1970 .duration_since(std::time::UNIX_EPOCH)
1971 .map(|d| d.as_nanos())
1972 .unwrap_or(0);
1973 format!("fresh-terminal-eph-{}-{}", predicted_terminal_id.0, nanos)
1974 };
1975 let log_path = terminal_root.join(format!("{}.log", name_stem));
1976 let backing_path = terminal_root.join(format!("{}.txt", name_stem));
1977 self.terminal_backing_files
1978 .insert(predicted_terminal_id, backing_path);
1979 let backing_path_for_spawn = self
1980 .terminal_backing_files
1981 .get(&predicted_terminal_id)
1982 .cloned();
1983
1984 match self.terminal_manager.spawn(
1985 cols,
1986 rows,
1987 Some(working_dir),
1988 Some(log_path.clone()),
1989 backing_path_for_spawn,
1990 self.resolved_terminal_wrapper(),
1991 ) {
1992 Ok(terminal_id) => {
1993 self.terminal_log_files
1995 .insert(terminal_id, log_path.clone());
1996 if terminal_id != predicted_terminal_id {
2004 let existing =
2005 self.terminal_backing_files.remove(&predicted_terminal_id);
2006 let fixed_backing = if persistent {
2007 terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
2008 } else {
2009 existing.unwrap_or_else(|| {
2010 terminal_root.join(format!("{}.txt", name_stem))
2011 })
2012 };
2013 self.terminal_backing_files
2014 .insert(terminal_id, fixed_backing);
2015 }
2016 if !persistent {
2017 self.ephemeral_terminals.insert(terminal_id);
2018 }
2019
2020 let active_split = self.split_manager.active_split();
2034 let buffer_id = if direction.is_some() {
2035 self.create_terminal_buffer_detached(terminal_id)
2036 } else {
2037 self.create_terminal_buffer_attached(terminal_id, active_split)
2038 };
2039
2040 let created_split_id = if let Some(dir_str) = direction.as_deref() {
2041 let split_dir = match dir_str {
2042 "horizontal" => crate::model::event::SplitDirection::Horizontal,
2043 _ => crate::model::event::SplitDirection::Vertical,
2044 };
2045
2046 let split_ratio = ratio.unwrap_or(0.5);
2047 match self
2048 .split_manager
2049 .split_active(split_dir, buffer_id, split_ratio)
2050 {
2051 Ok(new_split_id) => {
2052 let mut view_state = SplitViewState::with_buffer(
2053 self.terminal_width,
2054 self.terminal_height,
2055 buffer_id,
2056 );
2057 view_state.apply_config_defaults(
2058 self.config.editor.line_numbers,
2059 self.config.editor.highlight_current_line,
2060 false,
2061 false,
2062 None,
2063 self.config.editor.rulers.clone(),
2064 );
2065 view_state.viewport.line_wrap_enabled = false;
2069 self.split_view_states.insert(new_split_id, view_state);
2070
2071 if focus.unwrap_or(true) {
2072 self.split_manager.set_active_split(new_split_id);
2073 }
2074
2075 tracing::info!(
2076 "Created {:?} split for terminal {:?} with buffer {:?}",
2077 split_dir,
2078 terminal_id,
2079 buffer_id
2080 );
2081 Some(new_split_id)
2082 }
2083 Err(e) => {
2084 tracing::error!(
2085 "Failed to create split for terminal: {}; \
2086 falling back to active split",
2087 e
2088 );
2089 if let Some(view_state) =
2094 self.split_view_states.get_mut(&active_split)
2095 {
2096 view_state.add_buffer(buffer_id);
2097 view_state.viewport.line_wrap_enabled = false;
2098 }
2099 self.set_active_buffer(buffer_id);
2100 None
2101 }
2102 }
2103 } else {
2104 self.set_active_buffer(buffer_id);
2106 None
2107 };
2108
2109 self.resize_visible_terminals();
2111
2112 let result = fresh_core::api::TerminalResult {
2114 buffer_id: buffer_id.0 as u64,
2115 terminal_id: terminal_id.0 as u64,
2116 split_id: created_split_id.map(|s| s.0 .0 as u64),
2117 };
2118 self.plugin_manager.resolve_callback(
2119 fresh_core::api::JsCallbackId::from(request_id),
2120 serde_json::to_string(&result).unwrap_or_default(),
2121 );
2122
2123 tracing::info!(
2124 "Plugin created terminal {:?} with buffer {:?}",
2125 terminal_id,
2126 buffer_id
2127 );
2128 }
2129 Err(e) => {
2130 tracing::error!("Failed to create terminal for plugin: {}", e);
2131 self.plugin_manager.reject_callback(
2132 fresh_core::api::JsCallbackId::from(request_id),
2133 format!("Failed to create terminal: {}", e),
2134 );
2135 }
2136 }
2137 }
2138
2139 PluginCommand::SendTerminalInput { terminal_id, data } => {
2140 if let Some(handle) = self.terminal_manager.get(terminal_id) {
2141 handle.write(data.as_bytes());
2142 tracing::trace!(
2143 "Plugin sent {} bytes to terminal {:?}",
2144 data.len(),
2145 terminal_id
2146 );
2147 } else {
2148 tracing::warn!(
2149 "Plugin tried to send input to non-existent terminal {:?}",
2150 terminal_id
2151 );
2152 }
2153 }
2154
2155 PluginCommand::CloseTerminal { terminal_id } => {
2156 let buffer_to_close = self
2158 .terminal_buffers
2159 .iter()
2160 .find(|(_, &tid)| tid == terminal_id)
2161 .map(|(&bid, _)| bid);
2162
2163 if let Some(buffer_id) = buffer_to_close {
2164 if let Err(e) = self.close_buffer(buffer_id) {
2165 tracing::warn!("Failed to close terminal buffer: {}", e);
2166 }
2167 tracing::info!("Plugin closed terminal {:?}", terminal_id);
2168 } else {
2169 self.terminal_manager.close(terminal_id);
2171 tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
2172 }
2173 }
2174
2175 PluginCommand::GrepProject {
2176 pattern,
2177 fixed_string,
2178 case_sensitive,
2179 max_results,
2180 whole_words,
2181 callback_id,
2182 } => {
2183 self.handle_grep_project(
2184 pattern,
2185 fixed_string,
2186 case_sensitive,
2187 max_results,
2188 whole_words,
2189 callback_id,
2190 );
2191 }
2192
2193 PluginCommand::GrepProjectStreaming {
2194 pattern,
2195 fixed_string,
2196 case_sensitive,
2197 max_results,
2198 whole_words,
2199 search_id,
2200 callback_id,
2201 } => {
2202 self.handle_grep_project_streaming(
2203 pattern,
2204 fixed_string,
2205 case_sensitive,
2206 max_results,
2207 whole_words,
2208 search_id,
2209 callback_id,
2210 );
2211 }
2212
2213 PluginCommand::ReplaceInBuffer {
2214 file_path,
2215 matches,
2216 replacement,
2217 callback_id,
2218 } => {
2219 self.handle_replace_in_buffer(file_path, matches, replacement, callback_id);
2220 }
2221 }
2222 Ok(())
2223 }
2224
2225 fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
2227 if let Some(state) = self.buffers.get_mut(&buffer_id) {
2228 match state.buffer.save_to_file(&path) {
2230 Ok(()) => {
2231 if let Err(e) = self.finalize_save(Some(path)) {
2234 tracing::warn!("Failed to finalize save: {}", e);
2235 }
2236 tracing::debug!("Saved buffer {:?} to path", buffer_id);
2237 }
2238 Err(e) => {
2239 self.handle_set_status(format!("Error saving: {}", e));
2240 tracing::error!("Failed to save buffer to path: {}", e);
2241 }
2242 }
2243 } else {
2244 self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
2245 tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
2246 }
2247 }
2248
2249 #[cfg(feature = "plugins")]
2251 fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
2252 match self.plugin_manager.load_plugin(&path) {
2253 Ok(()) => {
2254 tracing::info!("Loaded plugin from {:?}", path);
2255 self.plugin_manager
2256 .resolve_callback(callback_id, "true".to_string());
2257 }
2258 Err(e) => {
2259 tracing::error!("Failed to load plugin from {:?}: {}", path, e);
2260 self.plugin_manager
2261 .reject_callback(callback_id, format!("{}", e));
2262 }
2263 }
2264 }
2265
2266 #[cfg(feature = "plugins")]
2268 fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
2269 match self.plugin_manager.unload_plugin(&name) {
2270 Ok(()) => {
2271 tracing::info!("Unloaded plugin: {}", name);
2272 self.plugin_manager
2273 .resolve_callback(callback_id, "true".to_string());
2274 }
2275 Err(e) => {
2276 tracing::error!("Failed to unload plugin '{}': {}", name, e);
2277 self.plugin_manager
2278 .reject_callback(callback_id, format!("{}", e));
2279 }
2280 }
2281 }
2282
2283 #[cfg(feature = "plugins")]
2285 fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
2286 match self.plugin_manager.reload_plugin(&name) {
2287 Ok(()) => {
2288 tracing::info!("Reloaded plugin: {}", name);
2289 self.plugin_manager
2290 .resolve_callback(callback_id, "true".to_string());
2291 }
2292 Err(e) => {
2293 tracing::error!("Failed to reload plugin '{}': {}", name, e);
2294 self.plugin_manager
2295 .reject_callback(callback_id, format!("{}", e));
2296 }
2297 }
2298 }
2299
2300 #[cfg(feature = "plugins")]
2302 fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
2303 let plugins = self.plugin_manager.list_plugins();
2304 let json_array: Vec<serde_json::Value> = plugins
2306 .iter()
2307 .map(|p| {
2308 serde_json::json!({
2309 "name": p.name,
2310 "path": p.path.to_string_lossy(),
2311 "enabled": p.enabled
2312 })
2313 })
2314 .collect();
2315 let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
2316 self.plugin_manager.resolve_callback(callback_id, json_str);
2317 }
2318
2319 fn handle_execute_action(&mut self, action_name: String) {
2321 use crate::input::keybindings::Action;
2322 use std::collections::HashMap;
2323
2324 if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
2326 if let Err(e) = self.handle_action(action) {
2328 tracing::warn!("Failed to execute action '{}': {}", action_name, e);
2329 } else {
2330 tracing::debug!("Executed action: {}", action_name);
2331 }
2332 } else {
2333 tracing::warn!("Unknown action: {}", action_name);
2334 }
2335 }
2336
2337 fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
2340 use crate::input::keybindings::Action;
2341 use std::collections::HashMap;
2342
2343 for action_spec in actions {
2344 if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
2345 for _ in 0..action_spec.count {
2347 if let Err(e) = self.handle_action(action.clone()) {
2348 tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
2349 return; }
2351 }
2352 tracing::debug!(
2353 "Executed action '{}' {} time(s)",
2354 action_spec.action,
2355 action_spec.count
2356 );
2357 } else {
2358 tracing::warn!("Unknown action: {}", action_spec.action);
2359 return; }
2361 }
2362 }
2363
2364 fn handle_get_buffer_text(
2366 &mut self,
2367 buffer_id: BufferId,
2368 start: usize,
2369 end: usize,
2370 request_id: u64,
2371 ) {
2372 let result = if let Some(state) = self.buffers.get_mut(&buffer_id) {
2373 let len = state.buffer.len();
2375 if start <= end && end <= len {
2376 Ok(state.get_text_range(start, end))
2377 } else {
2378 Err(format!(
2379 "Invalid range {}..{} for buffer of length {}",
2380 start, end, len
2381 ))
2382 }
2383 } else {
2384 Err(format!("Buffer {:?} not found", buffer_id))
2385 };
2386
2387 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2389 match result {
2390 Ok(text) => {
2391 let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
2393 self.plugin_manager.resolve_callback(callback_id, json);
2394 }
2395 Err(error) => {
2396 self.plugin_manager.reject_callback(callback_id, error);
2397 }
2398 }
2399 }
2400
2401 fn handle_set_editor_mode(&mut self, mode: Option<String>) {
2403 self.editor_mode = mode.clone();
2404 tracing::debug!("Set editor mode: {:?}", mode);
2405 }
2406
2407 fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2409 let actual_buffer_id = if buffer_id.0 == 0 {
2411 self.active_buffer_id()
2412 } else {
2413 buffer_id
2414 };
2415
2416 let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2417 let line_number = line as usize;
2419 let buffer_len = state.buffer.len();
2420
2421 if line_number == 0 {
2422 Some(0)
2424 } else {
2425 let mut current_line = 0;
2427 let mut line_start = None;
2428
2429 let content = state.get_text_range(0, buffer_len);
2431 for (byte_idx, c) in content.char_indices() {
2432 if c == '\n' {
2433 current_line += 1;
2434 if current_line == line_number {
2435 line_start = Some(byte_idx + 1);
2437 break;
2438 }
2439 }
2440 }
2441 line_start
2442 }
2443 } else {
2444 None
2445 };
2446
2447 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2449 let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2451 self.plugin_manager.resolve_callback(callback_id, json);
2452 }
2453
2454 fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2457 let actual_buffer_id = if buffer_id.0 == 0 {
2459 self.active_buffer_id()
2460 } else {
2461 buffer_id
2462 };
2463
2464 let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2465 let line_number = line as usize;
2466 let buffer_len = state.buffer.len();
2467
2468 let content = state.get_text_range(0, buffer_len);
2470 let mut current_line = 0;
2471 let mut line_end = None;
2472
2473 for (byte_idx, c) in content.char_indices() {
2474 if c == '\n' {
2475 if current_line == line_number {
2476 line_end = Some(byte_idx);
2478 break;
2479 }
2480 current_line += 1;
2481 }
2482 }
2483
2484 if line_end.is_none() && current_line == line_number {
2486 line_end = Some(buffer_len);
2487 }
2488
2489 line_end
2490 } else {
2491 None
2492 };
2493
2494 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2495 let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2496 self.plugin_manager.resolve_callback(callback_id, json);
2497 }
2498
2499 fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
2501 let actual_buffer_id = if buffer_id.0 == 0 {
2503 self.active_buffer_id()
2504 } else {
2505 buffer_id
2506 };
2507
2508 let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2509 let buffer_len = state.buffer.len();
2510 let content = state.get_text_range(0, buffer_len);
2511
2512 if content.is_empty() {
2514 Some(1) } else {
2516 let newline_count = content.chars().filter(|&c| c == '\n').count();
2517 let ends_with_newline = content.ends_with('\n');
2519 if ends_with_newline {
2520 Some(newline_count)
2521 } else {
2522 Some(newline_count + 1)
2523 }
2524 }
2525 } else {
2526 None
2527 };
2528
2529 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2530 let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
2531 self.plugin_manager.resolve_callback(callback_id, json);
2532 }
2533
2534 fn handle_scroll_to_line_center(
2536 &mut self,
2537 split_id: SplitId,
2538 buffer_id: BufferId,
2539 line: usize,
2540 ) {
2541 let actual_split_id = if split_id.0 == 0 {
2543 self.split_manager.active_split()
2544 } else {
2545 LeafId(split_id)
2546 };
2547
2548 let actual_buffer_id = if buffer_id.0 == 0 {
2550 self.active_buffer()
2551 } else {
2552 buffer_id
2553 };
2554
2555 let viewport_height = if let Some(view_state) = self.split_view_states.get(&actual_split_id)
2557 {
2558 view_state.viewport.height as usize
2559 } else {
2560 return;
2561 };
2562
2563 let lines_above = viewport_height / 2;
2565 let target_line = line.saturating_sub(lines_above);
2566
2567 if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
2569 let buffer = &mut state.buffer;
2570 if let Some(view_state) = self.split_view_states.get_mut(&actual_split_id) {
2571 view_state.viewport.scroll_to(buffer, target_line);
2572 view_state.viewport.set_skip_ensure_visible();
2574 }
2575 }
2576 }
2577
2578 fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
2588 if !self.buffers.contains_key(&buffer_id) {
2589 return;
2590 }
2591
2592 let mut target_leaves: Vec<LeafId> = Vec::new();
2594
2595 for leaf_id in self.split_manager.root().leaf_split_ids() {
2597 if let Some(vs) = self.split_view_states.get(&leaf_id) {
2598 if vs.active_buffer == buffer_id {
2599 target_leaves.push(leaf_id);
2600 }
2601 }
2602 }
2603
2604 for (_group_leaf_id, node) in self.grouped_subtrees.iter() {
2606 if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
2607 for inner_leaf in layout.leaf_split_ids() {
2608 if let Some(vs) = self.split_view_states.get(&inner_leaf) {
2609 if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
2610 target_leaves.push(inner_leaf);
2611 }
2612 }
2613 }
2614 }
2615 }
2616
2617 if target_leaves.is_empty() {
2618 return;
2619 }
2620
2621 let state = match self.buffers.get_mut(&buffer_id) {
2622 Some(s) => s,
2623 None => return,
2624 };
2625
2626 for leaf_id in target_leaves {
2627 let Some(view_state) = self.split_view_states.get_mut(&leaf_id) else {
2628 continue;
2629 };
2630 let viewport_height = view_state.viewport.height as usize;
2631 let lines_above = viewport_height / 3;
2634 let target = line.saturating_sub(lines_above);
2635 view_state.viewport.scroll_to(&mut state.buffer, target);
2636 view_state.viewport.set_skip_ensure_visible();
2637 }
2638 }
2639}
2640
2641#[cfg(test)]
2642mod tests {
2643 use tokio::io::{AsyncReadExt, BufReader};
2656 use tokio::process::Command as TokioCommand;
2657 use tokio::time::{timeout, Duration};
2658
2659 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2670 async fn kill_via_oneshot_terminates_long_running_child() {
2671 let mut cmd = TokioCommand::new("sleep");
2672 cmd.args(["30"]);
2673 cmd.stdout(std::process::Stdio::piped());
2674 cmd.stderr(std::process::Stdio::piped());
2675
2676 let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
2677 let pid = child.id().expect("child has a pid");
2678
2679 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
2680 let stdout_pipe = child.stdout.take();
2681 let stderr_pipe = child.stderr.take();
2682
2683 let stdout_fut = async {
2684 let mut buf = String::new();
2685 if let Some(s) = stdout_pipe {
2686 #[allow(clippy::let_underscore_must_use)]
2687 let _ = BufReader::new(s).read_to_string(&mut buf).await;
2688 }
2689 buf
2690 };
2691 let stderr_fut = async {
2692 let mut buf = String::new();
2693 if let Some(s) = stderr_pipe {
2694 #[allow(clippy::let_underscore_must_use)]
2695 let _ = BufReader::new(s).read_to_string(&mut buf).await;
2696 }
2697 buf
2698 };
2699 let wait_fut = async {
2700 tokio::select! {
2701 status = child.wait() => {
2702 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
2703 }
2704 _ = &mut kill_rx => {
2705 #[allow(clippy::let_underscore_must_use)]
2706 let _ = child.start_kill();
2707 child
2708 .wait()
2709 .await
2710 .map(|s| s.code().unwrap_or(-1))
2711 .unwrap_or(-1)
2712 }
2713 }
2714 };
2715
2716 tokio::time::sleep(Duration::from_millis(50)).await;
2721 kill_tx.send(()).expect("kill channel send");
2722
2723 let result = timeout(Duration::from_secs(5), async {
2724 tokio::join!(stdout_fut, stderr_fut, wait_fut)
2725 })
2726 .await;
2727
2728 let (_stdout, _stderr, exit_code) = result.expect(
2729 "kill path must resolve within 5s — if this times out the \
2730 select! arm order or kill-then-wait logic is broken",
2731 );
2732 assert_ne!(
2744 exit_code, 0,
2745 "killed child must exit non-success (got 0 — did the \
2746 kill arm fire too late, or did sleep somehow complete?)"
2747 );
2748
2749 #[cfg(unix)]
2758 {
2759 let still_alive = std::process::Command::new("kill")
2760 .args(["-0", &pid.to_string()])
2761 .status()
2762 .map(|s| s.success())
2763 .unwrap_or(false);
2764 assert!(
2765 !still_alive,
2766 "process {pid} must be reaped after wait() — a still-\
2767 alive check means the kill path leaked the child"
2768 );
2769 }
2770 #[cfg(not(unix))]
2771 {
2772 let _ = pid;
2775 }
2776 }
2777}