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::window::Window;
25use super::{Editor, FloatingWidgetState};
26
27fn normalize_plugin_path(path: std::path::PathBuf) -> std::path::PathBuf {
43 #[cfg(windows)]
44 {
45 let canonical = canonicalize_deepest_existing(&path);
46 let s = canonical.to_string_lossy();
47 if let Some(stripped) = s.strip_prefix(r"\\?\") {
48 return std::path::PathBuf::from(stripped);
49 }
50 return canonical;
51 }
52 #[cfg(not(windows))]
53 path
54}
55
56#[cfg(windows)]
57fn canonicalize_deepest_existing(path: &std::path::Path) -> std::path::PathBuf {
58 if let Ok(c) = path.canonicalize() {
59 return c;
60 }
61 let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
65 let mut ancestor = path;
66 loop {
67 let Some(parent) = ancestor.parent() else {
68 return path.to_path_buf();
69 };
70 if let Some(name) = ancestor.file_name() {
71 tail.push(name);
72 }
73 if let Ok(c) = parent.canonicalize() {
74 let mut out = c;
75 for name in tail.iter().rev() {
76 out.push(name);
77 }
78 return out;
79 }
80 ancestor = parent;
81 }
82}
83
84fn buffer_line_byte_offset(
89 content: &str,
90 buffer_len: usize,
91 line: usize,
92 want_end: bool,
93) -> Option<usize> {
94 if !want_end && line == 0 {
95 return Some(0);
96 }
97 let mut current_line = 0usize;
98 for (byte_idx, c) in content.char_indices() {
99 if c == '\n' {
100 if want_end && current_line == line {
101 return Some(byte_idx);
102 }
103 current_line += 1;
104 if !want_end && current_line == line {
105 return Some(byte_idx + 1);
106 }
107 }
108 }
109 if want_end && current_line == line {
110 Some(buffer_len)
111 } else {
112 None
113 }
114}
115
116impl Editor {
117 #[cfg(feature = "plugins")]
126 pub fn update_plugin_state_snapshot(&mut self) {
127 let Some(snapshot_handle) = self.plugin_manager.read().unwrap().state_snapshot_handle()
128 else {
129 return;
130 };
131 let mut snapshot = snapshot_handle.write().unwrap();
132
133 self.active_window_mut()
134 .populate_plugin_state_snapshot(&mut snapshot);
135
136 snapshot.clipboard = self.clipboard.get_internal().to_string();
140 snapshot.working_dir = self.working_dir().to_path_buf();
141
142 snapshot.terminal_width = self.terminal_width;
146 snapshot.terminal_height = self.terminal_height;
147
148 snapshot.authority_label = self.authority().display_label.clone();
155
156 snapshot.workspace_trust_level = self
159 .authority()
160 .workspace_trust
161 .level()
162 .as_str()
163 .to_string();
164 snapshot.env_active = self.authority().env_provider.is_active();
165
166 snapshot.detected_env = crate::services::workspace_trust::detect_env(
171 self.working_dir(),
172 &self.config.env.detectors,
173 )
174 .and_then(|d| serde_json::to_string(&d).ok())
175 .unwrap_or_default();
176
177 let dormant_infos = self.dormant_remote.values().map(|d| {
189 let slot = d.plugin_state.get("orchestrator");
190 let project_path = slot
191 .and_then(|m| m.get("project_path"))
192 .and_then(|v| v.as_str())
193 .filter(|p| !p.is_empty())
194 .map(std::path::PathBuf::from)
195 .or_else(|| d.project_path.clone())
196 .unwrap_or_else(|| d.root.clone());
197 fresh_core::api::WindowInfo {
198 id: fresh_core::WindowId(d.id),
199 label: d.label.clone(),
200 root: normalize_plugin_path(d.root.clone()),
201 project_path: normalize_plugin_path(project_path),
202 shared_worktree: d.shared_worktree,
203 }
204 });
205 let mut session_infos: Vec<fresh_core::api::WindowInfo> = self
206 .windows
207 .values()
208 .map(|s| {
209 let slot = s.plugin_state.get("orchestrator");
210 let project_path = slot
217 .and_then(|m| m.get("project_path"))
218 .and_then(|v| v.as_str())
219 .filter(|p| !p.is_empty())
220 .map(std::path::PathBuf::from)
221 .unwrap_or_else(|| s.root.clone());
222 let shared_worktree = slot
223 .and_then(|m| m.get("shared_worktree"))
224 .and_then(|v| v.as_bool())
225 .unwrap_or(false);
226 fresh_core::api::WindowInfo {
227 id: s.id,
228 label: s.label.clone(),
229 root: normalize_plugin_path(s.root.clone()),
230 project_path: normalize_plugin_path(project_path),
231 shared_worktree,
232 }
233 })
234 .collect();
235 session_infos.extend(dormant_infos);
236 session_infos.sort_by_key(|s| s.id.0);
237 snapshot.windows = session_infos;
238 snapshot.active_window_id = self.active_window;
239
240 if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
249 let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
250 self.config_cached_json = Arc::new(json);
251 self.config_snapshot_anchor = Arc::clone(&self.config);
252 }
253 snapshot.config = Arc::clone(&self.config_cached_json);
254
255 snapshot.user_config = Arc::clone(&self.user_config_raw);
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
272 pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
274 match command {
275 PluginCommand::InsertText {
277 buffer_id,
278 position,
279 text,
280 } => {
281 self.handle_insert_text(buffer_id, position, text);
282 }
283 PluginCommand::DeleteRange { buffer_id, range } => {
284 self.handle_delete_range(buffer_id, range);
285 }
286 PluginCommand::InsertAtCursor { text } => {
287 self.handle_insert_at_cursor(text);
288 }
289 PluginCommand::DeleteSelection => {
290 self.handle_delete_selection();
291 }
292
293 PluginCommand::AddOverlay {
295 buffer_id,
296 namespace,
297 range,
298 options,
299 } => {
300 self.handle_add_overlay(buffer_id, namespace, range, options);
301 }
302 PluginCommand::RemoveOverlay { buffer_id, handle } => {
303 self.handle_remove_overlay(buffer_id, handle);
304 }
305 PluginCommand::ClearAllOverlays { buffer_id } => {
306 self.handle_clear_all_overlays(buffer_id);
307 }
308 PluginCommand::ClearNamespace {
309 buffer_id,
310 namespace,
311 } => {
312 self.handle_clear_namespace(buffer_id, namespace);
313 }
314 PluginCommand::ClearOverlaysInRange {
315 buffer_id,
316 start,
317 end,
318 } => {
319 self.handle_clear_overlays_in_range(buffer_id, start, end);
320 }
321 PluginCommand::ClearOverlaysInRangeForNamespace {
322 buffer_id,
323 namespace,
324 start,
325 end,
326 } => {
327 self.handle_clear_overlays_in_range_for_namespace(buffer_id, namespace, start, end);
328 }
329
330 PluginCommand::AddVirtualText {
332 buffer_id,
333 virtual_text_id,
334 position,
335 text,
336 color,
337 use_bg,
338 before,
339 } => {
340 self.handle_add_virtual_text(
341 buffer_id,
342 virtual_text_id,
343 position,
344 text,
345 color,
346 use_bg,
347 before,
348 );
349 }
350 PluginCommand::AddVirtualTextStyled {
351 buffer_id,
352 virtual_text_id,
353 position,
354 text,
355 fg,
356 bg,
357 bold,
358 italic,
359 before,
360 } => {
361 self.handle_add_virtual_text_styled(
362 buffer_id,
363 virtual_text_id,
364 position,
365 text,
366 fg,
367 bg,
368 bold,
369 italic,
370 before,
371 );
372 }
373 PluginCommand::RemoveVirtualText {
374 buffer_id,
375 virtual_text_id,
376 } => {
377 self.handle_remove_virtual_text(buffer_id, virtual_text_id);
378 }
379 PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
380 self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
381 }
382 PluginCommand::ClearVirtualTexts { buffer_id } => {
383 self.handle_clear_virtual_texts(buffer_id);
384 }
385 PluginCommand::AddVirtualLine {
386 buffer_id,
387 position,
388 text,
389 fg_color,
390 bg_color,
391 above,
392 namespace,
393 priority,
394 gutter_glyph,
395 gutter_color,
396 text_overlays,
397 } => {
398 self.handle_add_virtual_line(
399 buffer_id,
400 position,
401 text,
402 fg_color,
403 bg_color,
404 above,
405 namespace,
406 priority,
407 gutter_glyph,
408 gutter_color,
409 text_overlays,
410 );
411 }
412 PluginCommand::ClearVirtualTextNamespace {
413 buffer_id,
414 namespace,
415 } => {
416 self.handle_clear_virtual_text_namespace(buffer_id, namespace);
417 }
418
419 PluginCommand::AddConceal {
421 buffer_id,
422 namespace,
423 start,
424 end,
425 replacement,
426 } => {
427 self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
428 }
429 PluginCommand::ClearConcealNamespace {
430 buffer_id,
431 namespace,
432 } => {
433 self.handle_clear_conceal_namespace(buffer_id, namespace);
434 }
435 PluginCommand::ClearConcealsInRange {
436 buffer_id,
437 start,
438 end,
439 } => {
440 self.handle_clear_conceals_in_range(buffer_id, start, end);
441 }
442 PluginCommand::ClearConcealsInRangeForNamespace {
443 buffer_id,
444 namespace,
445 start,
446 end,
447 } => {
448 self.handle_clear_conceals_in_range_for_namespace(buffer_id, namespace, start, end);
449 }
450
451 PluginCommand::AddFold {
452 buffer_id,
453 start,
454 end,
455 placeholder,
456 } => {
457 self.handle_add_fold(buffer_id, start, end, placeholder);
458 }
459 PluginCommand::ClearFolds { buffer_id } => {
460 self.handle_clear_folds(buffer_id);
461 }
462 PluginCommand::SetFoldingRanges { buffer_id, ranges } => {
463 self.handle_set_folding_ranges(buffer_id, ranges);
464 }
465
466 PluginCommand::AddSoftBreak {
468 buffer_id,
469 namespace,
470 position,
471 indent,
472 } => {
473 self.handle_add_soft_break(buffer_id, namespace, position, indent);
474 }
475 PluginCommand::ClearSoftBreakNamespace {
476 buffer_id,
477 namespace,
478 } => {
479 self.handle_clear_soft_break_namespace(buffer_id, namespace);
480 }
481 PluginCommand::ClearSoftBreaksInRange {
482 buffer_id,
483 start,
484 end,
485 } => {
486 self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
487 }
488
489 PluginCommand::AddMenuItem {
491 menu_label,
492 item,
493 position,
494 } => {
495 self.handle_add_menu_item(menu_label, item, position);
496 }
497 PluginCommand::AddMenu { menu, position } => {
498 self.handle_add_menu(menu, position);
499 }
500 PluginCommand::RemoveMenuItem {
501 menu_label,
502 item_label,
503 } => {
504 self.handle_remove_menu_item(menu_label, item_label);
505 }
506 PluginCommand::RemoveMenu { menu_label } => {
507 self.handle_remove_menu(menu_label);
508 }
509
510 PluginCommand::FocusSplit { split_id } => {
512 self.handle_focus_split(split_id);
513 }
514 PluginCommand::SetSplitBuffer {
515 split_id,
516 buffer_id,
517 } => {
518 self.handle_set_split_buffer(split_id, buffer_id);
519 }
520 PluginCommand::SetSplitScroll { split_id, top_byte } => {
521 self.handle_set_split_scroll(split_id, top_byte);
522 }
523 PluginCommand::RequestHighlights {
524 buffer_id,
525 range,
526 request_id,
527 } => {
528 self.handle_request_highlights(buffer_id, range, request_id);
529 }
530 PluginCommand::CloseSplit { split_id } => {
531 self.handle_close_split(split_id);
532 }
533 PluginCommand::SetSplitRatio { split_id, ratio } => {
534 self.handle_set_split_ratio(split_id, ratio);
535 }
536 PluginCommand::SetSplitLabel { split_id, label } => {
537 self.handle_set_split_label(split_id, label);
538 }
539 PluginCommand::ClearSplitLabel { split_id } => {
540 self.handle_clear_split_label(split_id);
541 }
542 PluginCommand::GetSplitByLabel { label, request_id } => {
543 self.handle_get_split_by_label(label, request_id);
544 }
545 PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
546 self.handle_distribute_splits_evenly();
547 }
548 PluginCommand::SetBufferCursor {
549 buffer_id,
550 position,
551 } => {
552 self.handle_set_buffer_cursor(buffer_id, position);
553 }
554 PluginCommand::SetBufferShowCursors { buffer_id, show } => {
555 self.handle_set_buffer_show_cursors(buffer_id, show);
556 }
557
558 PluginCommand::SetLayoutHints {
560 buffer_id,
561 split_id,
562 range: _,
563 hints,
564 } => {
565 self.handle_set_layout_hints(buffer_id, split_id, hints);
566 }
567 PluginCommand::SetLineNumbers { buffer_id, enabled } => {
568 self.handle_set_line_numbers(buffer_id, enabled);
569 }
570 PluginCommand::SetViewMode { buffer_id, mode } => {
571 self.handle_set_view_mode(buffer_id, &mode);
572 }
573 PluginCommand::SetLineWrap {
574 buffer_id,
575 split_id,
576 enabled,
577 } => {
578 self.handle_set_line_wrap(buffer_id, split_id, enabled);
579 }
580 PluginCommand::SubmitViewTransform {
581 buffer_id,
582 split_id,
583 payload,
584 } => {
585 self.handle_submit_view_transform(buffer_id, split_id, payload);
586 }
587 PluginCommand::ClearViewTransform {
588 buffer_id: _,
589 split_id,
590 } => {
591 self.handle_clear_view_transform(split_id);
592 }
593 PluginCommand::SetViewState {
594 buffer_id,
595 key,
596 value,
597 } => {
598 self.handle_set_view_state(buffer_id, key, value);
599 }
600 PluginCommand::SetGlobalState {
601 plugin_name,
602 key,
603 value,
604 } => {
605 self.handle_set_global_state(plugin_name, key, value);
606 }
607 PluginCommand::SetWindowState {
608 plugin_name,
609 key,
610 value,
611 } => {
612 self.handle_set_session_state(plugin_name, key, value);
613 }
614 PluginCommand::RefreshLines { buffer_id } => {
615 self.handle_refresh_lines(buffer_id);
616 }
617 PluginCommand::RefreshAllLines => {
618 self.handle_refresh_all_lines();
619 }
620 PluginCommand::HookCompleted { .. } => {
621 }
623 PluginCommand::SetLineIndicator {
624 buffer_id,
625 line,
626 namespace,
627 symbol,
628 color,
629 priority,
630 } => {
631 self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
632 }
633 PluginCommand::SetLineIndicators {
634 buffer_id,
635 lines,
636 namespace,
637 symbol,
638 color,
639 priority,
640 } => {
641 self.handle_set_line_indicators(
642 buffer_id, lines, namespace, symbol, color, priority,
643 );
644 }
645 PluginCommand::ClearLineIndicators {
646 buffer_id,
647 namespace,
648 } => {
649 self.handle_clear_line_indicators(buffer_id, namespace);
650 }
651 PluginCommand::SetFileExplorerDecorations {
652 namespace,
653 decorations,
654 } => {
655 self.active_window_mut()
656 .handle_set_file_explorer_decorations(namespace, decorations);
657 }
658 PluginCommand::ClearFileExplorerDecorations { namespace } => {
659 self.active_window_mut()
660 .handle_clear_file_explorer_decorations(&namespace);
661 }
662 PluginCommand::SetFileExplorerSlots { namespace, slots } => {
663 self.active_window_mut()
664 .handle_set_file_explorer_slots(namespace, slots);
665 }
666 PluginCommand::ClearFileExplorerSlots { namespace } => {
667 self.active_window_mut()
668 .handle_clear_file_explorer_slots(&namespace);
669 }
670
671 PluginCommand::SetStatus { message } => {
673 self.handle_set_status(message);
674 }
675 PluginCommand::ApplyTheme { theme_name } => {
676 self.apply_theme(&theme_name);
677 }
678 PluginCommand::OverrideThemeColors { overrides } => {
679 self.handle_override_theme_colors(overrides);
680 }
681 PluginCommand::ReloadConfig => {
682 self.reload_config();
683 }
684 PluginCommand::SetSetting { path, value, .. } => {
685 self.handle_set_setting(path, value);
686 }
687 PluginCommand::AddPluginConfigField {
688 plugin_name,
689 field_name,
690 field_schema,
691 } => {
692 self.handle_add_plugin_config_field(plugin_name, field_name, field_schema);
693 }
694 PluginCommand::ReloadThemes { apply_theme } => {
695 self.handle_reload_themes(apply_theme);
696 }
697 PluginCommand::RegisterGrammar {
698 language,
699 grammar_path,
700 extensions,
701 } => {
702 self.handle_register_grammar(language, grammar_path, extensions);
703 }
704 PluginCommand::RegisterLanguageConfig { language, config } => {
705 self.handle_register_language_config(language, config);
706 }
707 PluginCommand::RegisterLspServer { language, config } => {
708 self.handle_register_lsp_server(language, config);
709 }
710 PluginCommand::ReloadGrammars { callback_id } => {
711 self.handle_reload_grammars(callback_id);
712 }
713 PluginCommand::CancelPrompt => {
714 self.cancel_prompt();
715 }
716 PluginCommand::StartPrompt {
717 label,
718 prompt_type,
719 floating_overlay,
720 } => {
721 self.handle_start_prompt(label, prompt_type, floating_overlay);
722 }
723 PluginCommand::StartPromptWithInitial {
724 label,
725 prompt_type,
726 initial_value,
727 floating_overlay,
728 } => {
729 self.handle_start_prompt_with_initial(
730 label,
731 prompt_type,
732 initial_value,
733 floating_overlay,
734 );
735 }
736 PluginCommand::StartPromptAsync {
737 label,
738 initial_value,
739 callback_id,
740 } => {
741 self.handle_start_prompt_async(label, initial_value, callback_id);
742 }
743 PluginCommand::AwaitNextKey { callback_id } => {
744 self.handle_await_next_key(callback_id);
745 }
746 PluginCommand::SetKeyCaptureActive { active } => {
747 self.handle_set_key_capture_active(active);
748 }
749 PluginCommand::SetPromptSuggestions {
750 suggestions,
751 selected_index,
752 } => {
753 self.handle_set_prompt_suggestions(suggestions, selected_index);
754 }
755 PluginCommand::SetPromptInputSync { sync } => {
756 self.handle_set_prompt_input_sync(sync);
757 }
758 PluginCommand::SetPromptTitle { title } => {
759 self.handle_set_prompt_title(title);
760 }
761 PluginCommand::SetPromptFooter { footer } => {
762 self.handle_set_prompt_footer(footer);
763 }
764 PluginCommand::SetPromptToolbar { spec } => {
765 self.handle_set_prompt_toolbar(spec);
766 }
767 PluginCommand::ToggleOverlayToolbarWidget { key } => {
768 self.toggle_overlay_toolbar_widget(&key);
769 }
770 PluginCommand::SetPromptStatus { status } => {
771 self.handle_set_prompt_status(status);
772 }
773 PluginCommand::SetPromptSelectedIndex { index } => {
774 self.handle_set_prompt_selected_index(index);
775 }
776
777 PluginCommand::CreateWindow { root, label } => {
780 self.handle_create_window(root, label);
781 }
782 PluginCommand::CreateWindowWithTerminal {
783 root,
784 label,
785 cwd,
786 command,
787 title,
788 resume,
789 request_id,
790 } => {
791 self.handle_create_window_with_terminal(
792 root, label, cwd, command, title, resume, request_id,
793 );
794 }
795 PluginCommand::SetActiveWindow { id } => {
796 if self.dormant_remote.contains_key(&id) {
800 self.bring_dormant_remote_online(id);
801 } else {
802 self.set_active_window(id);
803 }
804 }
805 PluginCommand::SetActiveWindowAnimated { id, from_edge } => {
806 if self.dormant_remote.contains_key(&id) {
807 self.bring_dormant_remote_online(id);
808 } else {
809 self.set_active_window_animated(id, &from_edge);
810 }
811 }
812 PluginCommand::SetWindowCycleOrder { ids } => {
813 self.window_cycle_order = if ids.is_empty() { None } else { Some(ids) };
814 }
815 PluginCommand::CloseWindow { id } => {
816 let _ = self.close_window(id);
817 }
818 PluginCommand::PrewarmWindow { id } => {
819 self.prewarm_window(id);
820 }
821
822 PluginCommand::WatchPath {
824 path,
825 recursive,
826 request_id,
827 } => {
828 self.handle_watch_path(path, recursive, request_id);
829 }
830 PluginCommand::UnwatchPath { handle } => {
831 self.file_watcher_manager.unwatch(handle);
832 }
833
834 PluginCommand::PreviewWindowInRect { id } => {
835 self.handle_preview_window_in_rect(id);
836 }
837
838 PluginCommand::RegisterCommand { command } => {
840 self.handle_register_command(command);
841 }
842 PluginCommand::RegisterStatusBarElement {
843 plugin_name,
844 token_name,
845 title,
846 } => {
847 self.handle_register_status_bar_element(plugin_name, token_name, title);
848 }
849 PluginCommand::SetStatusBarValue {
850 buffer_id,
851 key,
852 value,
853 } => {
854 self.handle_set_status_bar_value(buffer_id, key, value);
855 }
856 PluginCommand::UnregisterCommand { name } => {
857 self.handle_unregister_command(name);
858 }
859 PluginCommand::DefineMode {
860 name,
861 bindings,
862 read_only,
863 allow_text_input,
864 inherit_normal_bindings,
865 plugin_name,
866 } => {
867 self.handle_define_mode(
868 name,
869 bindings,
870 read_only,
871 allow_text_input,
872 inherit_normal_bindings,
873 plugin_name,
874 );
875 }
876
877 PluginCommand::OpenFileInBackground { path, window_id } => {
879 self.handle_open_file_in_background_routed(path, window_id);
880 }
881 PluginCommand::OpenFileAtLocation { path, line, column } => {
882 return self.handle_open_file_at_location(path, line, column);
883 }
884 PluginCommand::OpenFileInSplit {
885 split_id,
886 path,
887 line,
888 column,
889 } => {
890 return self.handle_open_file_in_split(split_id, path, line, column);
891 }
892 PluginCommand::ShowBuffer { buffer_id } => {
893 self.handle_show_buffer(buffer_id);
894 }
895 PluginCommand::CloseBuffer { buffer_id } => {
896 self.handle_close_buffer(buffer_id);
897 }
898 PluginCommand::CloseOtherBuffersInSplit {
899 buffer_id,
900 split_id,
901 } => {
902 self.handle_close_other_buffers_in_split(buffer_id, split_id);
903 }
904 PluginCommand::CloseAllBuffersInSplit { split_id } => {
905 self.handle_close_all_buffers_in_split(split_id);
906 }
907 PluginCommand::CloseBuffersToRightInSplit {
908 buffer_id,
909 split_id,
910 } => {
911 self.handle_close_buffers_to_right_in_split(buffer_id, split_id);
912 }
913 PluginCommand::CloseBuffersToLeftInSplit {
914 buffer_id,
915 split_id,
916 } => {
917 self.handle_close_buffers_to_left_in_split(buffer_id, split_id);
918 }
919
920 PluginCommand::MoveTabLeft => {
921 self.handle_move_tab_left();
922 }
923 PluginCommand::MoveTabRight => {
924 self.handle_move_tab_right();
925 }
926
927 PluginCommand::StartAnimationArea { id, rect, kind } => {
929 self.handle_start_animation_area(id, rect, kind);
930 }
931 PluginCommand::StartAnimationVirtualBuffer {
932 id,
933 buffer_id,
934 kind,
935 } => {
936 self.handle_start_animation_virtual_buffer(id, buffer_id, kind);
937 }
938 PluginCommand::CancelAnimation { id } => {
939 self.handle_cancel_animation(id);
940 }
941
942 PluginCommand::SendLspRequest {
944 language,
945 method,
946 params,
947 request_id,
948 } => {
949 self.handle_send_lsp_request(language, method, params, request_id);
950 }
951
952 PluginCommand::SetClipboard { text } => {
954 self.handle_set_clipboard(text);
955 }
956
957 PluginCommand::SpawnProcess {
959 command,
960 args,
961 cwd,
962 stdout_to,
963 callback_id,
964 } => {
965 self.handle_spawn_process(command, args, cwd, stdout_to, callback_id);
966 }
967
968 PluginCommand::SpawnHostProcess {
969 command,
970 args,
971 cwd,
972 callback_id,
973 } => {
974 self.handle_spawn_host_process(command, args, cwd, callback_id);
975 }
976
977 PluginCommand::KillHostProcess { process_id } => {
978 self.handle_kill_host_process(process_id);
979 }
980
981 PluginCommand::SetAuthority { payload } => {
982 self.handle_set_authority(payload);
983 }
984
985 PluginCommand::AttachRemoteAgent {
986 payload,
987 request_id,
988 } => {
989 self.handle_attach_remote_agent(payload, request_id);
990 }
991
992 PluginCommand::CancelRemoteAttach => {
993 self.cancel_remote_attaches();
994 }
995
996 PluginCommand::ClearAuthority => {
997 self.handle_clear_authority();
998 }
999
1000 PluginCommand::SetEnv { snippet, dir } => {
1001 self.handle_set_env(snippet, dir);
1002 }
1003
1004 PluginCommand::ClearEnv => {
1005 self.handle_clear_env();
1006 }
1007
1008 PluginCommand::SetRemoteIndicatorState { state } => {
1009 self.handle_set_remote_indicator_state(state);
1010 }
1011
1012 PluginCommand::ClearRemoteIndicatorState => {
1013 self.remote_indicator_override = None;
1014 }
1015
1016 PluginCommand::SpawnProcessWait {
1017 process_id,
1018 callback_id,
1019 } => {
1020 self.handle_spawn_process_wait(process_id, callback_id);
1021 }
1022
1023 PluginCommand::Delay {
1024 callback_id,
1025 duration_ms,
1026 } => {
1027 self.handle_delay(callback_id, duration_ms);
1028 }
1029
1030 PluginCommand::HttpFetch {
1031 url,
1032 target_path,
1033 callback_id,
1034 } => {
1035 self.handle_http_fetch(url, target_path, callback_id);
1036 }
1037
1038 PluginCommand::SpawnBackgroundProcess {
1039 process_id,
1040 command,
1041 args,
1042 cwd,
1043 callback_id,
1044 } => {
1045 self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
1046 }
1047
1048 PluginCommand::KillBackgroundProcess { process_id } => {
1049 self.handle_kill_background_process(process_id);
1050 }
1051
1052 PluginCommand::CreateVirtualBuffer {
1054 name,
1055 mode,
1056 read_only,
1057 } => {
1058 self.handle_create_virtual_buffer(name, mode, read_only);
1059 }
1060 PluginCommand::CreateVirtualBufferWithContent {
1061 name,
1062 mode,
1063 read_only,
1064 entries,
1065 show_line_numbers,
1066 show_cursors,
1067 editing_disabled,
1068 hidden_from_tabs,
1069 initial_cursor_line,
1070 request_id,
1071 } => {
1072 self.handle_create_virtual_buffer_with_content(
1073 name,
1074 mode,
1075 read_only,
1076 entries,
1077 show_line_numbers,
1078 show_cursors,
1079 editing_disabled,
1080 hidden_from_tabs,
1081 initial_cursor_line,
1082 request_id,
1083 );
1084 }
1085 PluginCommand::CreateVirtualBufferInSplit {
1086 name,
1087 mode,
1088 read_only,
1089 entries,
1090 ratio,
1091 direction,
1092 panel_id,
1093 show_line_numbers,
1094 show_cursors,
1095 editing_disabled,
1096 line_wrap,
1097 before,
1098 role,
1099 request_id,
1100 } => {
1101 self.handle_create_virtual_buffer_in_split(
1102 name,
1103 mode,
1104 read_only,
1105 entries,
1106 ratio,
1107 direction,
1108 panel_id,
1109 show_line_numbers,
1110 show_cursors,
1111 editing_disabled,
1112 line_wrap,
1113 before,
1114 role,
1115 request_id,
1116 );
1117 }
1118 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1119 self.handle_set_virtual_buffer_content(buffer_id, entries);
1120 }
1121 PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1122 self.handle_get_text_properties_at_cursor(buffer_id);
1123 }
1124 PluginCommand::CreateVirtualBufferInExistingSplit {
1125 name,
1126 mode,
1127 read_only,
1128 entries,
1129 split_id,
1130 show_line_numbers,
1131 show_cursors,
1132 editing_disabled,
1133 line_wrap,
1134 initial_cursor_line,
1135 request_id,
1136 } => {
1137 self.handle_create_virtual_buffer_in_existing_split(
1138 name,
1139 mode,
1140 read_only,
1141 entries,
1142 split_id,
1143 show_line_numbers,
1144 show_cursors,
1145 editing_disabled,
1146 line_wrap,
1147 initial_cursor_line,
1148 request_id,
1149 );
1150 }
1151
1152 PluginCommand::SetContext { name, active } => {
1154 self.handle_set_context(name, active);
1155 }
1156
1157 PluginCommand::SetReviewDiffHunks { hunks } => {
1159 self.handle_set_review_diff_hunks(hunks);
1160 }
1161
1162 PluginCommand::ExecuteAction { action_name } => {
1164 self.handle_execute_action(action_name);
1165 }
1166 PluginCommand::ExecuteActions { actions } => {
1167 self.handle_execute_actions(actions);
1168 }
1169 PluginCommand::DefineMacro { register, steps } => {
1170 self.handle_define_macro(register, steps);
1171 }
1172 PluginCommand::PlayMacroByRegister { register } => {
1173 if let Some(key) = register.chars().next() {
1174 self.play_macro(key);
1175 }
1176 }
1177 PluginCommand::GetBufferText {
1178 buffer_id,
1179 start,
1180 end,
1181 request_id,
1182 } => {
1183 self.handle_get_buffer_text(buffer_id, start, end, request_id);
1184 }
1185 PluginCommand::GetLineStartPosition {
1186 buffer_id,
1187 line,
1188 request_id,
1189 } => {
1190 self.handle_get_line_start_position(buffer_id, line, request_id);
1191 }
1192 PluginCommand::GetLineEndPosition {
1193 buffer_id,
1194 line,
1195 request_id,
1196 } => {
1197 self.handle_get_line_end_position(buffer_id, line, request_id);
1198 }
1199 PluginCommand::GetBufferLineCount {
1200 buffer_id,
1201 request_id,
1202 } => {
1203 self.handle_get_buffer_line_count(buffer_id, request_id);
1204 }
1205 PluginCommand::GetCompositeCursorInfo { request_id } => {
1206 self.handle_get_composite_cursor_info(request_id);
1207 }
1208 PluginCommand::OpenFileStreaming { path, request_id } => {
1209 self.handle_open_file_streaming(path, request_id);
1210 }
1211 PluginCommand::RefreshBufferFromDisk {
1212 buffer_id,
1213 request_id,
1214 } => {
1215 self.handle_refresh_buffer_from_disk(buffer_id, request_id);
1216 }
1217 PluginCommand::SetBufferGroupPanelBuffer {
1218 group_id,
1219 panel_name,
1220 buffer_id,
1221 request_id,
1222 } => {
1223 self.handle_set_buffer_group_panel_buffer(
1224 group_id, panel_name, buffer_id, request_id,
1225 );
1226 }
1227 PluginCommand::ScrollToLineCenter {
1228 split_id,
1229 buffer_id,
1230 line,
1231 } => {
1232 self.handle_scroll_to_line_center(split_id, buffer_id, line);
1233 }
1234 PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1235 self.handle_scroll_buffer_to_line(buffer_id, line);
1236 }
1237 PluginCommand::SetEditorMode { mode } => {
1238 self.handle_set_editor_mode(mode);
1239 }
1240
1241 PluginCommand::ShowActionPopup {
1243 popup_id,
1244 title,
1245 message,
1246 actions,
1247 buffer_id,
1248 } => {
1249 self.handle_show_action_popup(popup_id, title, message, actions, buffer_id);
1250 }
1251
1252 PluginCommand::SetLspMenuContributions {
1253 plugin_id,
1254 language,
1255 items,
1256 } => {
1257 self.handle_set_lsp_menu_contributions(plugin_id, language, items);
1258 }
1259
1260 PluginCommand::DisableLspForLanguage { language } => {
1261 self.handle_disable_lsp_for_language(language);
1262 }
1263
1264 PluginCommand::RestartLspForLanguage { language } => {
1265 self.handle_restart_lsp_for_language(language);
1266 }
1267
1268 PluginCommand::SetLspRootUri { language, uri } => {
1269 self.handle_set_lsp_root_uri(language, uri);
1270 }
1271
1272 PluginCommand::CreateScrollSyncGroup {
1274 group_id,
1275 left_split,
1276 right_split,
1277 } => {
1278 self.handle_create_scroll_sync_group(group_id, left_split, right_split);
1279 }
1280 PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1281 self.handle_set_scroll_sync_anchors(group_id, anchors);
1282 }
1283 PluginCommand::RemoveScrollSyncGroup { group_id } => {
1284 self.handle_remove_scroll_sync_group(group_id);
1285 }
1286
1287 PluginCommand::CreateCompositeBuffer {
1289 name,
1290 mode,
1291 layout,
1292 sources,
1293 hunks,
1294 initial_focus_hunk,
1295 request_id,
1296 } => {
1297 self.handle_create_composite_buffer(
1298 name,
1299 mode,
1300 layout,
1301 sources,
1302 hunks,
1303 initial_focus_hunk,
1304 request_id,
1305 );
1306 }
1307 PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1308 self.handle_update_composite_alignment(buffer_id, hunks);
1309 }
1310 PluginCommand::CloseCompositeBuffer { buffer_id } => {
1311 self.active_window_mut().close_composite_buffer(buffer_id);
1312 }
1313 PluginCommand::FlushLayout => {
1314 self.flush_layout();
1315 }
1316 PluginCommand::CompositeNextHunk { buffer_id } => {
1317 self.handle_composite_next_hunk(buffer_id);
1318 }
1319 PluginCommand::CompositePrevHunk { buffer_id } => {
1320 self.handle_composite_prev_hunk(buffer_id);
1321 }
1322
1323 PluginCommand::CreateBufferGroup {
1325 name,
1326 mode,
1327 layout_json,
1328 request_id,
1329 } => {
1330 self.handle_create_buffer_group(name, mode, layout_json, request_id);
1331 }
1332 PluginCommand::SetPanelContent {
1333 group_id,
1334 panel_name,
1335 entries,
1336 } => {
1337 self.set_panel_content(group_id, panel_name, entries);
1338 }
1339 PluginCommand::CloseBufferGroup { group_id } => {
1340 self.close_buffer_group(group_id);
1341 }
1342 PluginCommand::FocusPanel {
1343 group_id,
1344 panel_name,
1345 } => {
1346 self.focus_panel(group_id, panel_name);
1347 }
1348
1349 PluginCommand::SaveBufferToPath { buffer_id, path } => {
1351 self.handle_save_buffer_to_path(buffer_id, path);
1352 }
1353
1354 #[cfg(feature = "plugins")]
1356 PluginCommand::LoadPlugin { path, callback_id } => {
1357 self.handle_load_plugin(path, callback_id);
1358 }
1359 #[cfg(feature = "plugins")]
1360 PluginCommand::UnloadPlugin { name, callback_id } => {
1361 self.handle_unload_plugin(name, callback_id);
1362 }
1363 #[cfg(feature = "plugins")]
1364 PluginCommand::ReloadPlugin { name, callback_id } => {
1365 self.handle_reload_plugin(name, callback_id);
1366 }
1367 #[cfg(feature = "plugins")]
1368 PluginCommand::ListPlugins { callback_id } => {
1369 self.handle_list_plugins(callback_id);
1370 }
1371 #[cfg(not(feature = "plugins"))]
1373 PluginCommand::LoadPlugin { .. }
1374 | PluginCommand::UnloadPlugin { .. }
1375 | PluginCommand::ReloadPlugin { .. }
1376 | PluginCommand::ListPlugins { .. } => {
1377 tracing::warn!("Plugin management commands require the 'plugins' feature");
1378 }
1379
1380 PluginCommand::CreateTerminal {
1382 cwd,
1383 direction,
1384 ratio,
1385 focus,
1386 persistent,
1387 window_id,
1388 command,
1389 title,
1390 request_id,
1391 } => {
1392 self.handle_create_terminal(
1393 cwd, direction, ratio, focus, persistent, window_id, command, title, request_id,
1394 );
1395 }
1396
1397 PluginCommand::SendTerminalInput { terminal_id, data } => {
1398 self.handle_send_terminal_input(terminal_id, data);
1399 }
1400
1401 PluginCommand::CloseTerminal { terminal_id } => {
1402 self.handle_close_terminal(terminal_id);
1403 }
1404
1405 PluginCommand::SignalWindow { id, signal } => {
1406 self.handle_signal_window(id, &signal);
1407 }
1408
1409 PluginCommand::GrepProject {
1410 pattern,
1411 fixed_string,
1412 case_sensitive,
1413 max_results,
1414 whole_words,
1415 callback_id,
1416 } => {
1417 self.handle_grep_project(
1418 pattern,
1419 fixed_string,
1420 case_sensitive,
1421 max_results,
1422 whole_words,
1423 callback_id,
1424 );
1425 }
1426
1427 PluginCommand::BeginSearch {
1428 pattern,
1429 fixed_string,
1430 case_sensitive,
1431 max_results,
1432 whole_words,
1433 source_buffer_id,
1434 handle_id,
1435 } => {
1436 self.handle_begin_search(
1437 pattern,
1438 fixed_string,
1439 case_sensitive,
1440 max_results,
1441 whole_words,
1442 source_buffer_id,
1443 handle_id,
1444 );
1445 }
1446
1447 PluginCommand::ReplaceInBuffer {
1448 file_path,
1449 buffer_id,
1450 matches,
1451 replacement,
1452 callback_id,
1453 } => {
1454 self.handle_replace_in_buffer(
1455 file_path,
1456 buffer_id,
1457 matches,
1458 replacement,
1459 callback_id,
1460 );
1461 }
1462
1463 PluginCommand::MountWidgetPanel {
1464 plugin,
1465 panel_id,
1466 buffer_id,
1467 spec,
1468 } => {
1469 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1470 self.handle_mount_widget_panel(key, buffer_id, spec);
1471 }
1472
1473 PluginCommand::UpdateWidgetPanel {
1474 plugin,
1475 panel_id,
1476 spec,
1477 } => {
1478 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1479 self.handle_update_widget_panel(&key, spec);
1480 }
1481
1482 PluginCommand::UnmountWidgetPanel { plugin, panel_id } => {
1483 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1484 self.handle_unmount_widget_panel(&key);
1485 }
1486
1487 PluginCommand::WidgetCommand {
1488 plugin,
1489 panel_id,
1490 action,
1491 } => {
1492 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1493 self.handle_widget_command(&key, action);
1494 }
1495
1496 PluginCommand::WidgetMutate {
1497 plugin,
1498 panel_id,
1499 mutation,
1500 } => {
1501 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1502 self.handle_widget_mutate(&key, mutation);
1503 }
1504
1505 PluginCommand::MountFloatingWidget {
1506 plugin,
1507 panel_id,
1508 spec,
1509 width_pct,
1510 height_pct,
1511 as_dock,
1512 focus_marker,
1513 } => {
1514 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1515 self.handle_mount_floating_widget(
1516 key,
1517 spec,
1518 width_pct,
1519 height_pct,
1520 as_dock,
1521 focus_marker,
1522 );
1523 }
1524
1525 PluginCommand::UpdateFloatingWidget {
1526 plugin,
1527 panel_id,
1528 spec,
1529 } => {
1530 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1531 self.handle_update_floating_widget(&key, spec);
1532 }
1533
1534 PluginCommand::UnmountFloatingWidget { plugin, panel_id } => {
1535 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1536 self.handle_unmount_floating_widget(&key);
1537 }
1538
1539 PluginCommand::FloatingPanelControl {
1540 plugin,
1541 panel_id,
1542 op,
1543 arg,
1544 } => {
1545 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1546 self.handle_floating_panel_control(&key, &op, arg);
1547 }
1548 }
1549 Ok(())
1550 }
1551
1552 fn handle_watch_path(&mut self, path: std::path::PathBuf, recursive: bool, request_id: u64) {
1555 let result = if let Some(ref bridge) = self.async_bridge {
1556 self.file_watcher_manager.watch(bridge, &path, recursive)
1557 } else {
1558 Err(
1559 "watchPath: no async bridge — file watching is unavailable in this build"
1560 .to_string(),
1561 )
1562 };
1563 self.last_watch_response_for_test = Some((request_id, result.clone()));
1564 self.send_plugin_response(fresh_core::api::PluginResponse::WatchPathRegistered {
1565 request_id,
1566 result,
1567 });
1568 }
1569
1570 fn handle_set_env(&mut self, snippet: String, dir: Option<String>) {
1571 use crate::services::workspace_trust::TrustLevel;
1575 if self.authority().workspace_trust.level() == TrustLevel::Trusted {
1576 self.authority()
1577 .env_provider
1578 .set(snippet, dir.map(std::path::PathBuf::from));
1579 self.refresh_active_window_lsp_for_env();
1582 } else {
1583 self.active_window_mut().status_message =
1584 Some("Workspace not trusted — cannot activate environment".to_string());
1585 }
1586 }
1587
1588 fn handle_clear_env(&mut self) {
1589 let was_active = self.authority().env_provider.is_active();
1590 self.authority().env_provider.clear();
1591 if was_active {
1592 self.refresh_active_window_lsp_for_env();
1593 }
1594 }
1595
1596 fn refresh_active_window_lsp_for_env(&mut self) {
1613 let active_id = self.active_window;
1614 let running: Vec<String> = self
1615 .windows
1616 .get(&active_id)
1617 .map(|w| w.lsp.running_servers())
1618 .unwrap_or_default();
1619 if running.is_empty() {
1620 return;
1621 }
1622 let file_path = self
1623 .active_window()
1624 .buffer_metadata
1625 .get(&self.active_buffer())
1626 .and_then(|meta| meta.file_path().cloned());
1627 for language in &running {
1628 if let Some(w) = self.windows.get_mut(&active_id) {
1629 let _ = w.lsp.manual_restart(language, file_path.as_deref());
1630 }
1631 self.reopen_buffers_for_language(language);
1634 }
1635 }
1636
1637 fn handle_open_file_in_background_routed(
1638 &mut self,
1639 path: std::path::PathBuf,
1640 window_id: Option<fresh_core::WindowId>,
1641 ) {
1642 let route_to_inactive =
1643 window_id.filter(|&id| id != self.active_window && self.windows.contains_key(&id));
1644 if let Some(target) = route_to_inactive {
1645 self.handle_open_file_in_inactive_session(target, path);
1646 } else {
1647 self.handle_open_file_in_background(path);
1648 }
1649 }
1650
1651 fn handle_set_split_label(&mut self, split_id: SplitId, label: String) {
1654 self.windows
1655 .get_mut(&self.active_window)
1656 .and_then(|w| w.split_manager_mut())
1657 .expect("active window must have a populated split layout")
1658 .set_label(LeafId(split_id), label);
1659 }
1660
1661 fn handle_clear_split_label(&mut self, split_id: SplitId) {
1662 self.windows
1663 .get_mut(&self.active_window)
1664 .and_then(|w| w.split_manager_mut())
1665 .expect("active window must have a populated split layout")
1666 .clear_label(split_id);
1667 }
1668
1669 fn handle_reload_themes(&mut self, apply_theme: Option<String>) {
1670 self.reload_themes();
1671 if let Some(theme_name) = apply_theme {
1672 self.apply_theme(&theme_name);
1673 }
1674 }
1675
1676 fn handle_set_key_capture_active(&mut self, active: bool) {
1677 self.active_window_mut().key_capture_active = active;
1678 if !active {
1679 self.active_window_mut().pending_key_capture_buffer.clear();
1682 }
1683 }
1684
1685 fn handle_set_prompt_input_sync(&mut self, sync: bool) {
1686 if let Some(prompt) = &mut self.active_window_mut().prompt {
1687 prompt.sync_input_on_navigate = sync;
1688 }
1689 }
1690
1691 fn handle_set_prompt_title(&mut self, title: Vec<fresh_core::api::StyledText>) {
1692 if let Some(prompt) = &mut self.active_window_mut().prompt {
1693 prompt.title = title;
1694 }
1695 }
1696
1697 fn handle_set_prompt_footer(&mut self, footer: Vec<fresh_core::api::StyledText>) {
1698 if let Some(prompt) = &mut self.active_window_mut().prompt {
1699 prompt.footer = footer;
1700 }
1701 }
1702
1703 fn handle_set_prompt_toolbar(&mut self, spec: Option<fresh_core::api::WidgetSpec>) {
1704 if let Some(prompt) = &mut self.active_window_mut().prompt {
1705 prompt.toolbar_widget = spec;
1706 }
1707 }
1708
1709 fn handle_set_prompt_status(&mut self, status: String) {
1710 if let Some(prompt) = &mut self.active_window_mut().prompt {
1711 prompt.status = status;
1712 }
1713 }
1714
1715 fn handle_set_prompt_selected_index(&mut self, index: u32) {
1716 if let Some(prompt) = &mut self.active_window_mut().prompt {
1717 let len = prompt.suggestions.len();
1718 if len > 0 {
1719 prompt.selected_suggestion = Some((index as usize).min(len - 1));
1720 }
1721 }
1722 }
1723
1724 fn handle_create_window(&mut self, root: std::path::PathBuf, label: String) {
1725 if !root.is_absolute() {
1726 tracing::warn!(
1727 "CreateWindow rejected: root must be absolute, got {:?}",
1728 root
1729 );
1730 } else {
1731 let _ = self.create_window_at(root, label);
1732 }
1733 }
1734
1735 fn handle_preview_window_in_rect(&mut self, id: Option<fresh_core::WindowId>) {
1736 self.preview_window_id = match id {
1739 Some(sid) if sid != self.active_window && self.windows.contains_key(&sid) => Some(sid),
1740 _ => None,
1741 };
1742 }
1743
1744 fn handle_register_status_bar_element(
1745 &mut self,
1746 plugin_name: String,
1747 token_name: String,
1748 title: String,
1749 ) {
1750 if let Err(e) = self.register_status_bar_element(&plugin_name, &token_name, &title) {
1751 tracing::warn!("Failed to register statusbar element: {}", e);
1752 }
1753 }
1754
1755 fn handle_set_status_bar_value(&mut self, buffer_id: u64, key: String, value: String) {
1756 if let Err(e) =
1757 self.set_status_bar_value(fresh_core::BufferId(buffer_id as usize), &key, value)
1758 {
1759 tracing::debug!("Skipped statusbar value for stale buffer: {}", e);
1762 }
1763 }
1764
1765 fn handle_cancel_animation(&mut self, id: u64) {
1766 self.active_window_mut()
1767 .animations
1768 .cancel(crate::view::animation::AnimationId::from_raw(id));
1769 }
1770
1771 fn handle_clear_authority(&mut self) {
1772 tracing::info!("Plugin cleared authority; restoring local");
1773 self.clear_authority();
1774 }
1775
1776 fn handle_set_review_diff_hunks(&mut self, hunks: Vec<fresh_core::api::ReviewHunk>) {
1777 self.active_window_mut().review_hunks = hunks;
1778 tracing::debug!(
1779 "Set {} review hunks",
1780 self.active_window_mut().review_hunks.len()
1781 );
1782 }
1783
1784 fn handle_composite_next_hunk(&mut self, buffer_id: fresh_core::BufferId) {
1785 let split_id = self.active_window().effective_active_pair().0;
1788 self.active_window_mut()
1789 .composite_next_hunk(split_id, buffer_id);
1790 }
1791
1792 fn handle_composite_prev_hunk(&mut self, buffer_id: fresh_core::BufferId) {
1793 let split_id = self.active_window().effective_active_pair().0;
1794 self.active_window_mut()
1795 .composite_prev_hunk(split_id, buffer_id);
1796 }
1797
1798 fn configure_vbuf_display(
1804 &mut self,
1805 buffer_id: crate::model::event::BufferId,
1806 show_line_numbers: bool,
1807 show_cursors: bool,
1808 editing_disabled: bool,
1809 ) {
1810 if let Some(state) = self
1811 .windows
1812 .get_mut(&self.active_window)
1813 .map(|w| &mut w.buffers)
1814 .expect("active window present")
1815 .get_mut(&buffer_id)
1816 {
1817 state.margins.configure_for_line_numbers(show_line_numbers);
1818 state.show_cursors = show_cursors;
1819 state.editing_disabled = editing_disabled;
1820 }
1821 }
1822
1823 #[allow(clippy::too_many_arguments)]
1828 fn route_vbuf_to_existing_dock(
1829 &mut self,
1830 dock_leaf: crate::model::event::LeafId,
1831 name: String,
1832 mode: String,
1833 read_only: bool,
1834 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1835 panel_id: Option<&str>,
1836 show_line_numbers: bool,
1837 show_cursors: bool,
1838 editing_disabled: bool,
1839 request_id: Option<u64>,
1840 ) {
1841 let source_split_before_create = self.split_manager().active_split();
1844 let buffer_id =
1845 self.active_window_mut()
1846 .create_virtual_buffer(name.clone(), mode, read_only);
1847 self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
1848 if let Some(pid) = panel_id {
1849 self.panel_ids_mut().insert(pid.to_string(), buffer_id);
1850 }
1851 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1852 tracing::error!("Failed to set virtual buffer content (dock route): {}", e);
1853 return;
1854 }
1855 self.split_manager_mut().set_active_split(dock_leaf);
1857 self.active_window_mut()
1858 .set_pane_buffer(dock_leaf, buffer_id);
1859 if dock_leaf != source_split_before_create {
1861 if let Some(source_view_state) = self
1862 .windows
1863 .get_mut(&self.active_window)
1864 .and_then(|w| w.split_view_states_mut())
1865 .expect("active window must have a populated split layout")
1866 .get_mut(&source_split_before_create)
1867 {
1868 source_view_state.remove_buffer(buffer_id);
1869 }
1870 }
1871 if let Some(req_id) = request_id {
1872 let result = fresh_core::api::VirtualBufferResult {
1873 buffer_id: buffer_id.0 as u64,
1874 split_id: Some(dock_leaf.0 .0 as u64),
1875 };
1876 self.plugin_manager.read().unwrap().resolve_callback(
1877 fresh_core::api::JsCallbackId::from(req_id),
1878 serde_json::to_string(&result).unwrap_or_default(),
1879 );
1880 }
1881 tracing::info!(
1882 "Routed virtual buffer '{}' into existing utility dock {:?}",
1883 name,
1884 dock_leaf
1885 );
1886 }
1887
1888 fn update_existing_vbuf_panel(
1891 &mut self,
1892 existing_buffer_id: crate::model::event::BufferId,
1893 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1894 request_id: Option<u64>,
1895 panel_name: &str,
1896 ) {
1897 match self.set_virtual_buffer_content(existing_buffer_id, entries) {
1898 Ok(()) => tracing::info!("Updated existing panel '{}' content", panel_name),
1899 Err(e) => tracing::error!("Failed to update panel content: {}", e),
1900 }
1901 let splits = self.split_manager().splits_for_buffer(existing_buffer_id);
1902 if let Some(&split_id) = splits.first() {
1903 self.split_manager_mut().set_active_split(split_id);
1904 self.active_window_mut()
1906 .set_pane_buffer(split_id, existing_buffer_id);
1907 tracing::debug!("Focused split {:?} containing panel buffer", split_id);
1908 }
1909 if let Some(req_id) = request_id {
1910 let result = fresh_core::api::VirtualBufferResult {
1911 buffer_id: existing_buffer_id.0 as u64,
1912 split_id: splits.first().map(|s| s.0 .0 as u64),
1913 };
1914 self.plugin_manager.read().unwrap().resolve_callback(
1915 fresh_core::api::JsCallbackId::from(req_id),
1916 serde_json::to_string(&result).unwrap_or_default(),
1917 );
1918 }
1919 }
1920
1921 fn handle_get_line_position(
1929 &mut self,
1930 buffer_id: crate::model::event::BufferId,
1931 line: u32,
1932 request_id: u64,
1933 want_end: bool,
1934 ) {
1935 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1936 let result = self
1937 .windows
1938 .get_mut(&self.active_window)
1939 .map(|w| &mut w.buffers)
1940 .expect("active window present")
1941 .get_mut(&actual_buffer_id)
1942 .and_then(|state| {
1943 let len = state.buffer.len();
1944 let content = state.get_text_range(0, len);
1945 buffer_line_byte_offset(&content, len, line as usize, want_end)
1946 });
1947 self.resolve_json_callback(request_id, result);
1948 }
1949
1950 fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1952 if let Some(state) = self
1953 .windows
1954 .get_mut(&self.active_window)
1955 .map(|w| &mut w.buffers)
1956 .expect("active window present")
1957 .get_mut(&buffer_id)
1958 {
1959 match state.buffer.save_to_file(&path) {
1961 Ok(()) => {
1962 if let Err(e) = self.finalize_save(Some(path)) {
1965 tracing::warn!("Failed to finalize save: {}", e);
1966 }
1967 tracing::debug!("Saved buffer {:?} to path", buffer_id);
1968 }
1969 Err(e) => {
1970 self.handle_set_status(format!("Error saving: {}", e));
1971 tracing::error!("Failed to save buffer to path: {}", e);
1972 }
1973 }
1974 } else {
1975 self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1976 tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1977 }
1978 }
1979
1980 #[cfg(feature = "plugins")]
1982 fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1983 let load_result = self.plugin_manager.read().unwrap().load_plugin(&path);
1984 match load_result {
1985 Ok(()) => {
1986 tracing::info!("Loaded plugin from {:?}", path);
1987 self.plugin_manager
1988 .read()
1989 .unwrap()
1990 .resolve_callback(callback_id, "true".to_string());
1991 }
1992 Err(e) => {
1993 tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1994 self.plugin_manager
1995 .read()
1996 .unwrap()
1997 .reject_callback(callback_id, format!("{}", e));
1998 }
1999 }
2000 }
2001
2002 #[cfg(feature = "plugins")]
2004 fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
2005 let result = self.plugin_manager.write().unwrap().unload_plugin(&name);
2008 match result {
2009 Ok(()) => {
2010 tracing::info!("Unloaded plugin: {}", name);
2011 if let Ok(mut schemas) = self.plugin_schemas.write() {
2012 schemas.remove(&name);
2013 }
2014 self.plugin_manager
2015 .read()
2016 .unwrap()
2017 .resolve_callback(callback_id, "true".to_string());
2018 }
2019 Err(e) => {
2020 tracing::error!("Failed to unload plugin '{}': {}", name, e);
2021 self.plugin_manager
2022 .read()
2023 .unwrap()
2024 .reject_callback(callback_id, format!("{}", e));
2025 }
2026 }
2027 }
2028
2029 #[cfg(feature = "plugins")]
2031 fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
2032 let path = self
2036 .plugin_manager
2037 .read()
2038 .unwrap()
2039 .list_plugins()
2040 .into_iter()
2041 .find(|p| p.name == name)
2042 .map(|p| p.path);
2043 let _ = path; let reload_result = self.plugin_manager.read().unwrap().reload_plugin(&name);
2045 match reload_result {
2046 Ok(()) => {
2047 tracing::info!("Reloaded plugin: {}", name);
2048 self.plugin_manager
2049 .read()
2050 .unwrap()
2051 .resolve_callback(callback_id, "true".to_string());
2052 }
2053 Err(e) => {
2054 tracing::error!("Failed to reload plugin '{}': {}", name, e);
2055 self.plugin_manager
2056 .read()
2057 .unwrap()
2058 .reject_callback(callback_id, format!("{}", e));
2059 }
2060 }
2061 }
2062
2063 #[cfg(feature = "plugins")]
2065 fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
2066 let plugins = self.plugin_manager.read().unwrap().list_plugins();
2067 let json_array: Vec<serde_json::Value> = plugins
2069 .iter()
2070 .map(|p| {
2071 serde_json::json!({
2072 "name": p.name,
2073 "path": p.path.to_string_lossy(),
2074 "enabled": p.enabled
2075 })
2076 })
2077 .collect();
2078 let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
2079 self.plugin_manager
2080 .read()
2081 .unwrap()
2082 .resolve_callback(callback_id, json_str);
2083 }
2084
2085 fn handle_execute_action(&mut self, action_name: String) {
2087 use crate::input::keybindings::Action;
2088 use std::collections::HashMap;
2089
2090 if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
2092 if let Err(e) = self.handle_action(action) {
2094 tracing::warn!("Failed to execute action '{}': {}", action_name, e);
2095 } else {
2096 tracing::debug!("Executed action: {}", action_name);
2097 }
2098 } else {
2099 tracing::warn!("Unknown action: {}", action_name);
2100 }
2101 }
2102
2103 fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
2106 use crate::input::keybindings::Action;
2107
2108 const PLUGIN_FORBIDDEN_ACTIONS: &[&str] = &[
2115 "workspace_trust_trust",
2116 "workspace_trust_restrict",
2117 "workspace_trust_block",
2118 ];
2119
2120 for action_spec in actions {
2121 if PLUGIN_FORBIDDEN_ACTIONS.contains(&action_spec.action.as_str()) {
2122 tracing::warn!(
2123 "plugin attempted to set workspace trust via '{}' — denied; \
2124 plugins may request the prompt (workspace_trust_prompt), not set the level",
2125 action_spec.action
2126 );
2127 continue;
2128 }
2129 if let Some(action) = Action::from_str(&action_spec.action, &action_spec.args) {
2130 for _ in 0..action_spec.count {
2132 if let Err(e) = self.handle_action(action.clone()) {
2133 tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
2134 return; }
2136 }
2137 tracing::debug!(
2138 "Executed action '{}' {} time(s)",
2139 action_spec.action,
2140 action_spec.count
2141 );
2142 } else {
2143 tracing::warn!("Unknown action: {}", action_spec.action);
2144 return; }
2146 }
2147 }
2148
2149 fn handle_define_macro(&mut self, register: String, steps: Vec<fresh_core::api::ActionSpec>) {
2156 use crate::input::keybindings::Action;
2157
2158 let Some(key) = register.chars().next() else {
2159 tracing::warn!("defineMacro: empty register key, ignoring");
2160 return;
2161 };
2162
2163 let mut actions = Vec::with_capacity(steps.len());
2164 for spec in &steps {
2165 match Action::from_str(&spec.action, &spec.args) {
2166 Some(action) => {
2167 for _ in 0..spec.count.max(1) {
2168 actions.push(action.clone());
2169 }
2170 }
2171 None => {
2172 tracing::warn!(
2173 "defineMacro['{}']: unknown action '{}' skipped",
2174 key,
2175 spec.action
2176 );
2177 }
2178 }
2179 }
2180
2181 let count = actions.len();
2182 self.active_window_mut().macros.define(key, actions);
2183 tracing::debug!("defineMacro['{}']: stored {} action(s)", key, count);
2184 }
2185
2186 fn handle_get_buffer_text(
2191 &mut self,
2192 buffer_id: BufferId,
2193 start: usize,
2194 end: usize,
2195 request_id: u64,
2196 ) {
2197 let result = if let Some(state) = self
2198 .windows
2199 .get_mut(&self.active_window)
2200 .map(|w| &mut w.buffers)
2201 .expect("active window present")
2202 .get_mut(&buffer_id)
2203 {
2204 let (start, end) = clamp_buffer_text_range(start, end, state.buffer.len());
2212 Ok(state.get_text_range(start, end))
2213 } else {
2214 Err(format!("Buffer {:?} not found", buffer_id))
2215 };
2216
2217 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2219 match result {
2220 Ok(text) => {
2221 let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
2223 self.plugin_manager
2224 .read()
2225 .unwrap()
2226 .resolve_callback(callback_id, json);
2227 }
2228 Err(error) => {
2229 self.plugin_manager
2230 .read()
2231 .unwrap()
2232 .reject_callback(callback_id, error);
2233 }
2234 }
2235 }
2236
2237 fn handle_set_editor_mode(&mut self, mode: Option<String>) {
2239 self.active_window_mut().editor_mode = mode.clone();
2240 tracing::debug!("Set editor mode: {:?}", mode);
2241 }
2242
2243 fn resolve_buffer_id(&self, buffer_id: BufferId) -> BufferId {
2245 if buffer_id.0 == 0 {
2246 self.active_buffer()
2247 } else {
2248 buffer_id
2249 }
2250 }
2251
2252 fn resolve_json_callback<T: serde::Serialize>(&mut self, request_id: u64, value: T) {
2254 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2255 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
2256 self.plugin_manager
2257 .read()
2258 .unwrap()
2259 .resolve_callback(callback_id, json);
2260 }
2261
2262 fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2264 self.handle_get_line_position(buffer_id, line, request_id, false);
2265 }
2266
2267 fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2270 self.handle_get_line_position(buffer_id, line, request_id, true);
2271 }
2272
2273 fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
2275 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2276
2277 let result = if let Some(state) = self
2278 .windows
2279 .get_mut(&self.active_window)
2280 .map(|w| &mut w.buffers)
2281 .expect("active window present")
2282 .get_mut(&actual_buffer_id)
2283 {
2284 let buffer_len = state.buffer.len();
2285 let content = state.get_text_range(0, buffer_len);
2286 let newlines = content.bytes().filter(|&b| b == b'\n').count();
2287 Some(if content.is_empty() {
2288 1
2289 } else {
2290 newlines + usize::from(!content.ends_with('\n'))
2291 })
2292 } else {
2293 None
2294 };
2295
2296 self.resolve_json_callback(request_id, result);
2297 }
2298
2299 fn handle_get_composite_cursor_info(&mut self, request_id: u64) {
2305 let info = self.active_window().active_composite_cursor_info();
2306 let value = info.map(|(focused_pane, pane_count, lines)| {
2307 serde_json::json!({
2308 "focusedPane": focused_pane,
2309 "paneCount": pane_count,
2310 "lines": lines,
2311 })
2312 });
2313 self.resolve_json_callback(request_id, value);
2314 }
2315
2316 fn handle_open_file_streaming(&mut self, path: std::path::PathBuf, request_id: u64) {
2333 if !self.authority().filesystem.exists(&path) {
2336 if let Some(parent) = path.parent() {
2337 if !parent.as_os_str().is_empty() {
2338 if let Err(e) = std::fs::create_dir_all(parent) {
2339 tracing::warn!(
2340 "openFileStreaming: failed to create parent dir {:?}: {}",
2341 parent,
2342 e
2343 );
2344 self.resolve_json_callback::<Option<u64>>(request_id, None);
2345 return;
2346 }
2347 }
2348 }
2349 if let Err(e) = std::fs::write(&path, b"") {
2350 tracing::warn!(
2351 "openFileStreaming: failed to create empty file at {:?}: {}",
2352 path,
2353 e
2354 );
2355 self.resolve_json_callback::<Option<u64>>(request_id, None);
2356 return;
2357 }
2358 }
2359
2360 let buffer_id = match self.open_file_no_focus(&path) {
2364 Ok(id) => id,
2365 Err(e) => {
2366 tracing::warn!(
2367 "openFileStreaming: open_file_no_focus failed for {:?}: {}",
2368 path,
2369 e
2370 );
2371 self.resolve_json_callback::<Option<u64>>(request_id, None);
2372 return;
2373 }
2374 };
2375
2376 if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2381 meta.hidden_from_tabs = true;
2382 meta.auto_revert_enabled = false;
2383 }
2384 let active_split = self
2385 .windows
2386 .get(&self.active_window)
2387 .and_then(|w| w.buffers.splits())
2388 .map(|(mgr, _)| mgr)
2389 .expect("active window must have a populated split layout")
2390 .active_split();
2391 if let Some(vs) = self
2392 .windows
2393 .get_mut(&self.active_window)
2394 .and_then(|w| w.split_view_states_mut())
2395 .expect("active window must have a populated split layout")
2396 .get_mut(&active_split)
2397 {
2398 use crate::view::split::TabTarget;
2399 vs.open_buffers
2400 .retain(|t| !matches!(t, TabTarget::Buffer(b) if *b == buffer_id));
2401 }
2402
2403 self.resolve_json_callback(request_id, Some(buffer_id.0));
2404 }
2405
2406 fn handle_set_buffer_group_panel_buffer(
2409 &mut self,
2410 group_id: usize,
2411 panel_name: String,
2412 buffer_id: BufferId,
2413 request_id: u64,
2414 ) {
2415 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2416 let ok = self.set_buffer_group_panel_buffer(group_id, panel_name, actual_buffer_id);
2417 self.resolve_json_callback(request_id, ok);
2418 }
2419
2420 fn handle_refresh_buffer_from_disk(&mut self, buffer_id: BufferId, request_id: u64) {
2424 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2425
2426 let path = self
2427 .windows
2428 .get(&self.active_window)
2429 .and_then(|w| w.buffers.splits())
2430 .map(|(_, _)| ())
2431 .and_then(|_| {
2432 self.windows
2433 .get(&self.active_window)?
2434 .buffers
2435 .get(&actual_buffer_id)?
2436 .buffer
2437 .file_path()
2438 .map(|p| p.to_path_buf())
2439 });
2440
2441 let Some(path) = path else {
2442 self.resolve_json_callback::<Option<usize>>(request_id, None);
2444 return;
2445 };
2446
2447 let new_size = match self.authority().filesystem.metadata(&path) {
2448 Ok(m) => m.size as usize,
2449 Err(_) => {
2450 self.resolve_json_callback::<Option<usize>>(request_id, None);
2451 return;
2452 }
2453 };
2454
2455 let new_total = if let Some(state) = self
2456 .windows
2457 .get_mut(&self.active_window)
2458 .map(|w| &mut w.buffers)
2459 .expect("active window present")
2460 .get_mut(&actual_buffer_id)
2461 {
2462 let old = state.buffer.total_bytes();
2463 if new_size > old {
2464 state.buffer.extend_streaming(&path, new_size);
2465 }
2466 state.buffer.total_bytes()
2467 } else {
2468 self.resolve_json_callback::<Option<usize>>(request_id, None);
2469 return;
2470 };
2471
2472 self.resolve_json_callback(request_id, Some(new_total));
2473 }
2474
2475 fn handle_scroll_to_line_center(
2477 &mut self,
2478 split_id: SplitId,
2479 buffer_id: BufferId,
2480 line: usize,
2481 ) {
2482 let actual_split_id = if split_id.0 == 0 {
2483 self.windows
2484 .get(&self.active_window)
2485 .and_then(|w| w.buffers.splits())
2486 .map(|(mgr, _)| mgr)
2487 .expect("active window must have a populated split layout")
2488 .active_split()
2489 } else {
2490 LeafId(split_id)
2491 };
2492 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2493
2494 let viewport_height = if let Some(view_state) = self
2496 .windows
2497 .get(&self.active_window)
2498 .and_then(|w| w.buffers.splits())
2499 .map(|(_, vs)| vs)
2500 .expect("active window must have a populated split layout")
2501 .get(&actual_split_id)
2502 {
2503 view_state.viewport.height as usize
2504 } else {
2505 return;
2506 };
2507
2508 let lines_above = viewport_height / 2;
2510 let target_line = line.saturating_sub(lines_above);
2511
2512 self.active_window_mut().scroll_split_viewport_to(
2513 actual_buffer_id,
2514 actual_split_id,
2515 target_line,
2516 true,
2517 );
2518 }
2519
2520 fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
2530 if !self
2531 .windows
2532 .get(&self.active_window)
2533 .map(|w| &w.buffers)
2534 .expect("active window present")
2535 .contains_key(&buffer_id)
2536 {
2537 return;
2538 }
2539
2540 let mut target_leaves: Vec<LeafId> = Vec::new();
2542
2543 for leaf_id in self
2545 .windows
2546 .get(&self.active_window)
2547 .and_then(|w| w.buffers.splits())
2548 .map(|(mgr, _)| mgr)
2549 .expect("active window must have a populated split layout")
2550 .root()
2551 .leaf_split_ids()
2552 {
2553 if let Some(vs) = self
2554 .windows
2555 .get(&self.active_window)
2556 .and_then(|w| w.buffers.splits())
2557 .map(|(_, vs)| vs)
2558 .expect("active window must have a populated split layout")
2559 .get(&leaf_id)
2560 {
2561 if vs.active_buffer == buffer_id {
2562 target_leaves.push(leaf_id);
2563 }
2564 }
2565 }
2566
2567 for (_group_leaf_id, node) in self.active_window().grouped_subtrees.iter() {
2569 if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
2570 for inner_leaf in layout.leaf_split_ids() {
2571 if let Some(vs) = self
2572 .windows
2573 .get(&self.active_window)
2574 .and_then(|w| w.buffers.splits())
2575 .map(|(_, vs)| vs)
2576 .expect("active window must have a populated split layout")
2577 .get(&inner_leaf)
2578 {
2579 if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
2580 target_leaves.push(inner_leaf);
2581 }
2582 }
2583 }
2584 }
2585 }
2586
2587 if target_leaves.is_empty() {
2588 return;
2589 }
2590
2591 self.active_window_mut()
2592 .scroll_buffer_to_line_in_splits(buffer_id, &target_leaves, line);
2593 }
2594
2595 fn handle_spawn_host_process(
2596 &mut self,
2597 command: String,
2598 args: Vec<String>,
2599 cwd: Option<String>,
2600 callback_id: JsCallbackId,
2601 ) {
2602 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2617 use tokio::io::{AsyncReadExt, BufReader};
2618 use tokio::process::Command as TokioCommand;
2619
2620 let effective_cwd = cwd.or_else(|| {
2621 std::env::current_dir()
2622 .map(|p| p.to_string_lossy().to_string())
2623 .ok()
2624 });
2625 let sender = bridge.sender();
2626 let process_id = callback_id.as_u64();
2627
2628 if let crate::services::workspace_trust::SpawnDecision::Deny(reason) = self
2635 .authority()
2636 .workspace_trust
2637 .decide(&command, effective_cwd.as_deref())
2638 {
2639 #[allow(clippy::let_underscore_must_use)]
2640 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2641 process_id,
2642 stdout: String::new(),
2643 stderr: reason,
2644 exit_code: -1,
2645 });
2646 return;
2647 }
2648
2649 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
2650 self.host_process_handles.insert(process_id, kill_tx);
2651
2652 runtime.spawn(async move {
2653 use crate::services::process_hidden::HideWindow;
2654 let mut cmd = TokioCommand::new(&command);
2655 cmd.args(&args);
2656 cmd.stdout(std::process::Stdio::piped());
2657 cmd.stderr(std::process::Stdio::piped());
2658 cmd.hide_window();
2659 if let Some(ref dir) = effective_cwd {
2660 cmd.current_dir(dir);
2661 }
2662 let mut child = match cmd.spawn() {
2663 Ok(c) => c,
2664 Err(e) => {
2665 #[allow(clippy::let_underscore_must_use)]
2666 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2667 process_id,
2668 stdout: String::new(),
2669 stderr: e.to_string(),
2670 exit_code: -1,
2671 });
2672 return;
2673 }
2674 };
2675
2676 let stdout_pipe = child.stdout.take();
2682 let stderr_pipe = child.stderr.take();
2683
2684 let stdout_fut = async {
2685 let mut buf = String::new();
2686 if let Some(s) = stdout_pipe {
2687 #[allow(clippy::let_underscore_must_use)]
2688 let _ = BufReader::new(s).read_to_string(&mut buf).await;
2689 }
2690 buf
2691 };
2692 let stderr_fut = async {
2693 let mut buf = String::new();
2694 if let Some(s) = stderr_pipe {
2695 #[allow(clippy::let_underscore_must_use)]
2696 let _ = BufReader::new(s).read_to_string(&mut buf).await;
2697 }
2698 buf
2699 };
2700 let wait_fut = async {
2701 tokio::select! {
2702 status = child.wait() => {
2703 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
2704 }
2705 _ = &mut kill_rx => {
2706 #[allow(clippy::let_underscore_must_use)]
2710 let _ = child.start_kill();
2711 child
2712 .wait()
2713 .await
2714 .map(|s| s.code().unwrap_or(-1))
2715 .unwrap_or(-1)
2716 }
2717 }
2718 };
2719 let (stdout, stderr, exit_code) = tokio::join!(stdout_fut, stderr_fut, wait_fut);
2720
2721 #[allow(clippy::let_underscore_must_use)]
2722 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2723 process_id,
2724 stdout,
2725 stderr,
2726 exit_code,
2727 });
2728 });
2729 } else {
2730 self.plugin_manager
2731 .read()
2732 .unwrap()
2733 .reject_callback(callback_id, "Async runtime not available".to_string());
2734 }
2735 }
2736
2737 fn handle_spawn_background_process(
2738 &mut self,
2739 process_id: u64,
2740 command: String,
2741 args: Vec<String>,
2742 cwd: Option<String>,
2743 callback_id: JsCallbackId,
2744 ) {
2745 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2747 use tokio::io::{AsyncBufReadExt, BufReader};
2748 use tokio::process::Command as TokioCommand;
2749
2750 let effective_cwd = cwd.unwrap_or_else(|| {
2751 std::env::current_dir()
2752 .map(|p| p.to_string_lossy().to_string())
2753 .unwrap_or_else(|_| ".".to_string())
2754 });
2755
2756 let sender = bridge.sender();
2757 let sender_stdout = sender.clone();
2758 let sender_stderr = sender.clone();
2759 let callback_id_u64 = callback_id.as_u64();
2760
2761 #[allow(clippy::let_underscore_must_use)]
2763 let handle = runtime.spawn(async move {
2764 use crate::services::process_hidden::HideWindow;
2765 let mut child = match TokioCommand::new(&command)
2766 .args(&args)
2767 .current_dir(&effective_cwd)
2768 .stdout(std::process::Stdio::piped())
2769 .stderr(std::process::Stdio::piped())
2770 .hide_window()
2771 .spawn()
2772 {
2773 Ok(child) => child,
2774 Err(e) => {
2775 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2776 fresh_core::api::PluginAsyncMessage::ProcessExit {
2777 process_id,
2778 callback_id: callback_id_u64,
2779 exit_code: -1,
2780 },
2781 ));
2782 tracing::error!("Failed to spawn background process: {}", e);
2783 return;
2784 }
2785 };
2786
2787 let stdout = child.stdout.take();
2789 let stderr = child.stderr.take();
2790 let pid = process_id;
2791
2792 if let Some(stdout) = stdout {
2794 let sender = sender_stdout;
2795 tokio::spawn(async move {
2796 let reader = BufReader::new(stdout);
2797 let mut lines = reader.lines();
2798 while let Ok(Some(line)) = lines.next_line().await {
2799 let _ =
2800 sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2801 fresh_core::api::PluginAsyncMessage::ProcessStdout {
2802 process_id: pid,
2803 data: line + "\n",
2804 },
2805 ));
2806 }
2807 });
2808 }
2809
2810 if let Some(stderr) = stderr {
2812 let sender = sender_stderr;
2813 tokio::spawn(async move {
2814 let reader = BufReader::new(stderr);
2815 let mut lines = reader.lines();
2816 while let Ok(Some(line)) = lines.next_line().await {
2817 let _ =
2818 sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2819 fresh_core::api::PluginAsyncMessage::ProcessStderr {
2820 process_id: pid,
2821 data: line + "\n",
2822 },
2823 ));
2824 }
2825 });
2826 }
2827
2828 let exit_code = match child.wait().await {
2830 Ok(status) => status.code().unwrap_or(-1),
2831 Err(_) => -1,
2832 };
2833
2834 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2835 fresh_core::api::PluginAsyncMessage::ProcessExit {
2836 process_id,
2837 callback_id: callback_id_u64,
2838 exit_code,
2839 },
2840 ));
2841 });
2842
2843 self.background_process_handles
2845 .insert(process_id, handle.abort_handle());
2846 } else {
2847 self.plugin_manager
2849 .read()
2850 .unwrap()
2851 .reject_callback(callback_id, "Async runtime not available".to_string());
2852 }
2853 }
2854
2855 #[allow(clippy::too_many_arguments)]
2856 fn handle_create_virtual_buffer_with_content(
2857 &mut self,
2858 name: String,
2859 mode: String,
2860 read_only: bool,
2861 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2862 show_line_numbers: bool,
2863 show_cursors: bool,
2864 editing_disabled: bool,
2865 hidden_from_tabs: bool,
2866 initial_cursor_line: Option<u32>,
2867 request_id: Option<u64>,
2868 ) {
2869 let buffer_id = if hidden_from_tabs {
2874 self.active_window_mut().create_virtual_buffer_detached(
2875 name.clone(),
2876 mode.clone(),
2877 read_only,
2878 )
2879 } else {
2880 self.active_window_mut()
2881 .create_virtual_buffer(name.clone(), mode.clone(), read_only)
2882 };
2883 tracing::info!(
2884 "Created virtual buffer '{}' with mode '{}' (id={:?}, detached={})",
2885 name,
2886 mode,
2887 buffer_id,
2888 hidden_from_tabs
2889 );
2890
2891 self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
2897 if !hidden_from_tabs {
2898 let active_split = self.split_manager().active_split();
2899 if let Some(view_state) = self
2900 .windows
2901 .get_mut(&self.active_window)
2902 .and_then(|w| w.split_view_states_mut())
2903 .expect("active window must have a populated split layout")
2904 .get_mut(&active_split)
2905 {
2906 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2907 }
2908 } else if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2909 meta.hidden_from_tabs = true;
2910 }
2911
2912 match self.set_virtual_buffer_content(buffer_id, entries) {
2914 Ok(()) => {
2915 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
2916 if !hidden_from_tabs {
2919 self.set_active_buffer(buffer_id);
2920 tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
2921 }
2922
2923 if let Some(line) = initial_cursor_line {
2934 let target_line = line as usize;
2935 let byte = self
2936 .windows
2937 .get_mut(&self.active_window)
2938 .and_then(|w| w.buffers.get_mut(&buffer_id))
2939 .map(|s| {
2940 let total = s.buffer.len();
2941 let mut iter = s.buffer.line_iterator(0, 80);
2942 let mut target_byte = 0;
2943 for current_line in 0..=target_line {
2944 if let Some((line_start, _)) = iter.next_line() {
2945 if current_line == target_line {
2946 target_byte = line_start;
2947 break;
2948 }
2949 } else {
2950 target_byte = total;
2951 break;
2952 }
2953 }
2954 target_byte
2955 })
2956 .unwrap_or(0);
2957 let splits: Vec<super::LeafId> = self
2958 .windows
2959 .get(&self.active_window)
2960 .and_then(|w| w.buffers.splits())
2961 .map(|(mgr, _)| mgr)
2962 .expect("active window must have a populated split layout")
2963 .splits_for_buffer(buffer_id);
2964 self.active_window_mut()
2965 .set_buffer_cursor_in_splits(buffer_id, byte, &splits);
2966 }
2967
2968 if let Some(req_id) = request_id {
2970 tracing::info!(
2971 "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
2972 req_id,
2973 buffer_id
2974 );
2975 let result = fresh_core::api::VirtualBufferResult {
2977 buffer_id: buffer_id.0 as u64,
2978 split_id: None,
2979 };
2980 self.plugin_manager.read().unwrap().resolve_callback(
2981 fresh_core::api::JsCallbackId::from(req_id),
2982 serde_json::to_string(&result).unwrap_or_default(),
2983 );
2984 tracing::info!(
2985 "CreateVirtualBufferWithContent: resolve_callback sent for request_id={}",
2986 req_id
2987 );
2988 }
2989 }
2990 Err(e) => {
2991 tracing::error!("Failed to set virtual buffer content: {}", e);
2992 }
2993 }
2994 }
2995
2996 #[allow(clippy::too_many_arguments)]
2997 fn handle_create_virtual_buffer_in_split(
2998 &mut self,
2999 name: String,
3000 mode: String,
3001 read_only: bool,
3002 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3003 ratio: f32,
3004 direction: Option<String>,
3005 panel_id: Option<String>,
3006 show_line_numbers: bool,
3007 show_cursors: bool,
3008 editing_disabled: bool,
3009 line_wrap: Option<bool>,
3010 before: bool,
3011 role: Option<String>,
3012 request_id: Option<u64>,
3013 ) {
3014 let split_role: Option<crate::view::split::SplitRole> = match role.as_deref() {
3017 Some("utility_dock") => Some(crate::view::split::SplitRole::UtilityDock),
3018 _ => None,
3019 };
3020
3021 if let Some(dock_leaf) = split_role.and_then(|r| self.split_manager().find_leaf_by_role(r))
3025 {
3026 return self.route_vbuf_to_existing_dock(
3027 dock_leaf,
3028 name,
3029 mode,
3030 read_only,
3031 entries,
3032 panel_id.as_deref(),
3033 show_line_numbers,
3034 show_cursors,
3035 editing_disabled,
3036 request_id,
3037 );
3038 }
3041
3042 if let Some(pid) = panel_id.as_deref() {
3045 let maybe_existing = self.panel_ids().get(pid).copied();
3046 if let Some(existing_id) = maybe_existing {
3047 let buffer_alive = self
3048 .windows
3049 .get(&self.active_window)
3050 .map(|w| w.buffers.contains_key(&existing_id))
3051 .unwrap_or(false);
3052 if buffer_alive {
3053 return self.update_existing_vbuf_panel(existing_id, entries, request_id, pid);
3054 }
3055 tracing::warn!(
3057 "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
3058 pid,
3059 existing_id
3060 );
3061 self.panel_ids_mut().remove(pid);
3062 }
3063 }
3064
3065 let source_split_before_create = self.split_manager().active_split();
3072
3073 let buffer_id =
3074 self.active_window_mut()
3075 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3076 tracing::info!(
3077 "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
3078 name,
3079 mode,
3080 buffer_id
3081 );
3082
3083 self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
3084
3085 if let Some(pid) = panel_id {
3086 self.panel_ids_mut().insert(pid, buffer_id);
3087 }
3088
3089 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
3090 tracing::error!("Failed to set virtual buffer content: {}", e);
3091 return;
3092 }
3093
3094 let split_dir = match direction.as_deref() {
3095 Some("vertical") => crate::model::event::SplitDirection::Vertical,
3096 _ => crate::model::event::SplitDirection::Horizontal,
3097 };
3098
3099 let split_result = if split_role == Some(crate::view::split::SplitRole::UtilityDock) {
3104 self.split_manager_mut()
3105 .split_root_positioned(split_dir, buffer_id, ratio, before)
3106 } else {
3107 self.split_manager_mut()
3108 .split_active_positioned(split_dir, buffer_id, ratio, before)
3109 };
3110
3111 let created_split_id = match split_result {
3112 Ok(new_split_id) => {
3113 if new_split_id != source_split_before_create {
3117 if let Some(src_vs) = self
3118 .windows
3119 .get_mut(&self.active_window)
3120 .and_then(|w| w.split_view_states_mut())
3121 .expect("active window must have a populated split layout")
3122 .get_mut(&source_split_before_create)
3123 {
3124 src_vs.remove_buffer(buffer_id);
3125 }
3126 }
3127
3128 let mut view_state = SplitViewState::with_buffer(
3129 self.terminal_width,
3130 self.terminal_height,
3131 buffer_id,
3132 );
3133 view_state.apply_config_defaults(
3134 self.config.editor.line_numbers,
3135 self.config.editor.highlight_current_line,
3136 line_wrap.unwrap_or_else(|| {
3137 self.active_window().resolve_line_wrap_for_buffer(buffer_id)
3138 }),
3139 self.config.editor.wrap_indent,
3140 self.active_window()
3141 .resolve_wrap_column_for_buffer(buffer_id),
3142 self.config.editor.rulers.clone(),
3143 self.config.editor.scroll_offset,
3144 );
3145 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
3146 self.windows
3147 .get_mut(&self.active_window)
3148 .and_then(|w| w.split_view_states_mut())
3149 .expect("active window must have a populated split layout")
3150 .insert(new_split_id, view_state);
3151
3152 self.split_manager_mut().set_active_split(new_split_id);
3153
3154 if let Some(target_role) = split_role {
3158 self.split_manager_mut().clear_role(target_role);
3159 self.split_manager_mut()
3160 .set_leaf_role(new_split_id, Some(target_role));
3161 tracing::info!(
3162 "Tagged new dock leaf {:?} with role {:?}",
3163 new_split_id,
3164 target_role
3165 );
3166 }
3167
3168 tracing::info!(
3169 "Created {:?} split with virtual buffer {:?}",
3170 split_dir,
3171 buffer_id
3172 );
3173 Some(new_split_id)
3174 }
3175 Err(e) => {
3176 tracing::error!("Failed to create split: {}", e);
3177 self.set_active_buffer(buffer_id);
3178 None
3179 }
3180 };
3181
3182 if let Some(req_id) = request_id {
3183 tracing::trace!(
3184 "CreateVirtualBufferInSplit: resolving callback for request_id={}, \
3185 buffer_id={:?}, split_id={:?}",
3186 req_id,
3187 buffer_id,
3188 created_split_id
3189 );
3190 let result = fresh_core::api::VirtualBufferResult {
3191 buffer_id: buffer_id.0 as u64,
3192 split_id: created_split_id.map(|s| s.0 .0 as u64),
3193 };
3194 self.plugin_manager.read().unwrap().resolve_callback(
3195 fresh_core::api::JsCallbackId::from(req_id),
3196 serde_json::to_string(&result).unwrap_or_default(),
3197 );
3198 }
3199 }
3200
3201 #[allow(clippy::too_many_arguments)]
3202 fn handle_create_virtual_buffer_in_existing_split(
3203 &mut self,
3204 name: String,
3205 mode: String,
3206 read_only: bool,
3207 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3208 split_id: SplitId,
3209 show_line_numbers: bool,
3210 show_cursors: bool,
3211 editing_disabled: bool,
3212 line_wrap: Option<bool>,
3213 initial_cursor_line: Option<u32>,
3214 request_id: Option<u64>,
3215 ) {
3216 let buffer_id =
3218 self.active_window_mut()
3219 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3220 tracing::info!(
3221 "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
3222 name,
3223 mode,
3224 split_id,
3225 buffer_id
3226 );
3227
3228 self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
3229
3230 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
3231 tracing::error!("Failed to set virtual buffer content: {}", e);
3232 return;
3233 }
3234
3235 let leaf_id = LeafId(split_id);
3242 self.windows
3243 .get_mut(&self.active_window)
3244 .and_then(|w| w.split_manager_mut())
3245 .expect("active window must have a populated split layout")
3246 .set_active_split(leaf_id);
3247 self.active_window_mut().set_pane_buffer(leaf_id, buffer_id);
3248
3249 if let Some(view_state) = self
3255 .windows
3256 .get_mut(&self.active_window)
3257 .and_then(|w| w.split_view_states_mut())
3258 .expect("active window must have a populated split layout")
3259 .get_mut(&leaf_id)
3260 {
3261 view_state.switch_buffer(buffer_id);
3262 view_state.add_buffer(buffer_id);
3263 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
3264
3265 if let Some(wrap) = line_wrap {
3267 view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
3268 }
3269 }
3270
3271 if let Some(line) = initial_cursor_line {
3280 let target_line = line as usize;
3281 let byte = self
3282 .windows
3283 .get_mut(&self.active_window)
3284 .and_then(|w| w.buffers.get_mut(&buffer_id))
3285 .map(|s| {
3286 let total = s.buffer.len();
3287 let mut iter = s.buffer.line_iterator(0, 80);
3288 let mut target_byte = 0;
3289 for current_line in 0..=target_line {
3290 if let Some((line_start, _)) = iter.next_line() {
3291 if current_line == target_line {
3292 target_byte = line_start;
3293 break;
3294 }
3295 } else {
3296 target_byte = total;
3297 break;
3298 }
3299 }
3300 target_byte
3301 })
3302 .unwrap_or(0);
3303 let mut splits: Vec<LeafId> = self
3310 .windows
3311 .get(&self.active_window)
3312 .and_then(|w| w.buffers.splits())
3313 .map(|(mgr, _)| mgr)
3314 .expect("active window must have a populated split layout")
3315 .splits_for_buffer(buffer_id);
3316 for node in self.active_window().grouped_subtrees.values() {
3317 if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
3318 for inner_leaf in layout.leaf_split_ids() {
3319 if let Some(vs) = self
3320 .windows
3321 .get(&self.active_window)
3322 .and_then(|w| w.buffers.splits())
3323 .map(|(_, vs)| vs)
3324 .expect("active window must have a populated split layout")
3325 .get(&inner_leaf)
3326 {
3327 if vs.active_buffer == buffer_id && !splits.contains(&inner_leaf) {
3328 splits.push(inner_leaf);
3329 }
3330 }
3331 }
3332 }
3333 }
3334 self.active_window_mut()
3335 .set_buffer_cursor_in_splits(buffer_id, byte, &splits);
3336 }
3337
3338 tracing::info!(
3339 "Displayed virtual buffer {:?} in split {:?}",
3340 buffer_id,
3341 split_id
3342 );
3343
3344 if let Some(req_id) = request_id {
3346 let result = fresh_core::api::VirtualBufferResult {
3347 buffer_id: buffer_id.0 as u64,
3348 split_id: Some(split_id.0 as u64),
3349 };
3350 self.plugin_manager.read().unwrap().resolve_callback(
3351 fresh_core::api::JsCallbackId::from(req_id),
3352 serde_json::to_string(&result).unwrap_or_default(),
3353 );
3354 }
3355 }
3356
3357 fn handle_show_action_popup(
3358 &mut self,
3359 popup_id: String,
3360 title: String,
3361 message: String,
3362 actions: Vec<fresh_core::api::ActionPopupAction>,
3363 buffer_id: Option<usize>,
3364 ) {
3365 tracing::info!(
3366 "Action popup requested: id={}, title={}, actions={}, buffer_id={:?}",
3367 popup_id,
3368 title,
3369 actions.len(),
3370 buffer_id,
3371 );
3372
3373 let items: Vec<crate::model::event::PopupListItemData> = actions
3375 .iter()
3376 .map(|action| crate::model::event::PopupListItemData {
3377 text: action.label.clone(),
3378 detail: None,
3379 icon: None,
3380 data: Some(action.id.clone()),
3381 })
3382 .collect();
3383
3384 drop(actions);
3389
3390 let popup_data = crate::model::event::PopupData {
3392 kind: crate::model::event::PopupKindHint::List,
3393 title: Some(title),
3394 description: Some(message),
3395 transient: false,
3396 content: crate::model::event::PopupContentData::List { items, selected: 0 },
3397 position: crate::model::event::PopupPositionData::BottomRight,
3398 width: 60,
3399 max_height: 15,
3400 bordered: true,
3401 };
3402
3403 let (popup_bg, popup_border_fg) = {
3413 let theme = self.theme();
3414 (theme.popup_bg, theme.popup_border_fg)
3415 };
3416 let mut popup_obj =
3417 crate::state::convert_popup_data_to_popup(&popup_data, popup_bg, popup_border_fg);
3418 popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
3419 popup_id: popup_id.clone(),
3420 };
3421
3422 if let Some(bid) = buffer_id {
3430 let bid = BufferId(bid);
3431 let stack = self
3432 .windows
3433 .values_mut()
3434 .find_map(|w| w.buffers.get_mut(&bid))
3435 .map(|state| &mut state.popups);
3436 if let Some(stack) = stack {
3437 let existing_idx = stack.all().iter().position(|p| {
3440 matches!(
3441 &p.resolver,
3442 crate::view::popup::PopupResolver::PluginAction { popup_id: id } if id == &popup_id,
3443 )
3444 });
3445 match existing_idx.and_then(|idx| stack.get_mut(idx)) {
3446 Some(slot) => *slot = popup_obj,
3447 None => stack.show(popup_obj),
3448 }
3449 tracing::info!("Action popup shown on buffer {:?}: id={}", bid, popup_id,);
3450 return;
3451 }
3452 tracing::warn!(
3453 "Action popup id={} requested for missing buffer {:?}; showing globally",
3454 popup_id,
3455 bid,
3456 );
3457 }
3458
3459 while self
3471 .active_state()
3472 .popups
3473 .top()
3474 .is_some_and(|p| matches!(p.resolver, crate::view::popup::PopupResolver::LspStatus))
3475 {
3476 self.active_state_mut().popups.hide();
3477 }
3478
3479 let existing_idx = self.global_popups.all().iter().position(|p| {
3486 matches!(
3487 &p.resolver,
3488 crate::view::popup::PopupResolver::PluginAction { popup_id: id } if id == &popup_id,
3489 )
3490 });
3491 if let Some(idx) = existing_idx {
3492 if let Some(slot) = self.global_popups.get_mut(idx) {
3493 *slot = popup_obj;
3494 }
3495 } else {
3496 self.global_popups.show(popup_obj);
3497 }
3498 tracing::info!(
3499 "Action popup shown: id={}, stack_depth={}",
3500 popup_id,
3501 self.global_popups.all().len()
3502 );
3503 }
3504
3505 fn handle_set_lsp_menu_contributions(
3515 &mut self,
3516 plugin_id: String,
3517 language: String,
3518 items: Vec<fresh_core::api::LspMenuItem>,
3519 ) {
3520 let key = (language.clone(), plugin_id.clone());
3521 if items.is_empty() {
3522 self.active_window_mut().lsp_menu_contributions.remove(&key);
3523 } else {
3524 self.active_window_mut()
3525 .lsp_menu_contributions
3526 .insert(key, items);
3527 }
3528 self.refresh_lsp_status_popup_if_open();
3533 }
3534
3535 #[allow(clippy::too_many_arguments)]
3536 fn handle_create_window_with_terminal(
3537 &mut self,
3538 root: std::path::PathBuf,
3539 label: String,
3540 cwd: Option<String>,
3541 command: Option<Vec<String>>,
3542 title: Option<String>,
3543 resume: Option<Vec<String>>,
3544 request_id: u64,
3545 ) {
3546 let callback_id = JsCallbackId::from(request_id);
3547 if !root.is_absolute() {
3548 let msg = format!(
3549 "createWindowWithTerminal: root must be absolute, got {:?}",
3550 root
3551 );
3552 tracing::warn!("{}", msg);
3553 self.plugin_manager
3554 .read()
3555 .unwrap()
3556 .reject_callback(callback_id, msg);
3557 return;
3558 }
3559 let cwd_buf = cwd.map(std::path::PathBuf::from);
3560 let new_authority = self.local_session_authority(&root);
3568 match self.create_window_with_terminal(
3569 root,
3570 label,
3571 cwd_buf,
3572 command,
3573 title,
3574 new_authority,
3575 resume,
3576 ) {
3577 Ok((window_id, terminal_id, buffer_id)) => {
3578 let api_result = fresh_core::api::SessionWithTerminalResult {
3579 window_id: window_id.0,
3580 terminal_id: terminal_id.0 as u64,
3581 buffer_id: buffer_id.0 as u64,
3582 };
3583 self.plugin_manager.read().unwrap().resolve_callback(
3584 callback_id,
3585 serde_json::to_string(&api_result).unwrap_or_default(),
3586 );
3587 }
3588 Err(e) => {
3589 tracing::error!("createWindowWithTerminal failed: {e}");
3590 self.plugin_manager
3591 .read()
3592 .unwrap()
3593 .reject_callback(callback_id, format!("createWindowWithTerminal: {e}"));
3594 }
3595 }
3596 }
3597
3598 #[allow(clippy::too_many_arguments)]
3599 fn handle_create_terminal(
3600 &mut self,
3601 cwd: Option<String>,
3602 direction: Option<String>,
3603 ratio: Option<f32>,
3604 focus: Option<bool>,
3605 persistent: bool,
3606 target_session_id: Option<fresh_core::WindowId>,
3607 command: Option<Vec<String>>,
3608 title: Option<String>,
3609 request_id: u64,
3610 ) {
3611 let target_id = target_session_id
3618 .filter(|id| self.windows.contains_key(id))
3619 .unwrap_or(self.active_window);
3620 let is_active_target = target_id == self.active_window;
3621
3622 let cwd_buf = cwd.map(std::path::PathBuf::from);
3623 let split_direction = direction.as_deref().map(|d| match d {
3624 "horizontal" => crate::model::event::SplitDirection::Horizontal,
3625 _ => crate::model::event::SplitDirection::Vertical,
3626 });
3627
3628 let prev_active = if is_active_target {
3636 Some(self.active_window().active_buffer())
3637 } else {
3638 None
3639 };
3640
3641 let result = {
3642 let target = self
3643 .windows
3644 .get_mut(&target_id)
3645 .expect("target window present (existence checked above)");
3646 target.create_plugin_terminal(
3647 cwd_buf,
3648 split_direction,
3649 ratio,
3650 focus.unwrap_or(true),
3651 persistent,
3652 command,
3653 title.filter(|t| !t.is_empty()),
3654 )
3655 };
3656 match result {
3657 Ok((terminal_id, buffer_id, created_split_id)) => {
3658 if is_active_target {
3659 let new_active = self.active_window().active_buffer();
3660 if prev_active != Some(new_active) {
3661 #[cfg(feature = "plugins")]
3662 self.update_plugin_state_snapshot();
3663 #[cfg(feature = "plugins")]
3664 self.plugin_manager.read().unwrap().run_hook(
3665 "buffer_activated",
3666 crate::services::plugins::hooks::HookArgs::BufferActivated {
3667 buffer_id: new_active,
3668 },
3669 );
3670 }
3671 }
3672 let api_result = fresh_core::api::TerminalResult {
3673 buffer_id: buffer_id.0 as u64,
3674 terminal_id: terminal_id.0 as u64,
3675 split_id: created_split_id.map(|s| s.0 .0 as u64),
3676 };
3677 self.plugin_manager.read().unwrap().resolve_callback(
3678 fresh_core::api::JsCallbackId::from(request_id),
3679 serde_json::to_string(&api_result).unwrap_or_default(),
3680 );
3681 tracing::info!(
3682 "Plugin created terminal {:?} with buffer {:?} in window {:?}",
3683 terminal_id,
3684 buffer_id,
3685 target_id
3686 );
3687 }
3688 Err(e) => {
3689 tracing::error!("Failed to create terminal for plugin: {e}");
3690 self.plugin_manager.read().unwrap().reject_callback(
3691 fresh_core::api::JsCallbackId::from(request_id),
3692 format!("Failed to create terminal: {e}"),
3693 );
3694 }
3695 }
3696 }
3697
3698 fn handle_get_split_by_label(&mut self, label: String, request_id: u64) {
3701 let split_id = self
3702 .windows
3703 .get(&self.active_window)
3704 .and_then(|w| w.buffers.splits())
3705 .map(|(mgr, _)| mgr)
3706 .expect("active window must have a populated split layout")
3707 .find_split_by_label(&label);
3708 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
3709 let json =
3710 serde_json::to_string(&split_id.map(|s| s.0 .0)).unwrap_or_else(|_| "null".to_string());
3711 self.plugin_manager
3712 .read()
3713 .unwrap()
3714 .resolve_callback(callback_id, json);
3715 }
3716
3717 fn handle_set_buffer_show_cursors(&mut self, buffer_id: BufferId, show: bool) {
3718 if let Some(state) = self
3719 .windows
3720 .get_mut(&self.active_window)
3721 .map(|w| &mut w.buffers)
3722 .expect("active window present")
3723 .get_mut(&buffer_id)
3724 {
3725 state.show_cursors = show;
3726 state.cursor_visibility_locked = true;
3729 } else {
3730 tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
3731 }
3732 }
3733
3734 fn handle_override_theme_colors(
3735 &mut self,
3736 overrides: std::collections::HashMap<String, [u8; 3]>,
3737 ) {
3738 let pairs = overrides
3739 .into_iter()
3740 .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
3741 let applied = self.theme.write().unwrap().override_colors(pairs);
3742 if applied > 0 {
3743 self.reapply_all_overlays();
3746 }
3747 }
3748
3749 fn handle_await_next_key(&mut self, callback_id: fresh_core::api::JsCallbackId) {
3750 if let Some(payload) = self
3754 .active_window_mut()
3755 .pending_key_capture_buffer
3756 .pop_front()
3757 {
3758 let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
3759 self.plugin_manager
3760 .read()
3761 .unwrap()
3762 .resolve_callback(callback_id, json);
3763 } else {
3764 self.active_window_mut()
3765 .pending_next_key_callbacks
3766 .push_back(callback_id);
3767 }
3768 }
3769
3770 fn handle_spawn_process(
3771 &mut self,
3772 command: String,
3773 args: Vec<String>,
3774 cwd: Option<String>,
3775 stdout_to: Option<std::path::PathBuf>,
3776 callback_id: fresh_core::api::JsCallbackId,
3777 ) {
3778 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3779 let effective_cwd = cwd.or_else(|| {
3780 std::env::current_dir()
3781 .map(|p| p.to_string_lossy().to_string())
3782 .ok()
3783 });
3784 let sender = bridge.sender();
3785 let spawner = self.authority().process_spawner.clone();
3786
3787 let process_id = callback_id.as_u64();
3792 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
3793 self.host_process_handles.insert(process_id, kill_tx);
3794
3795 runtime.spawn(async move {
3796 #[allow(clippy::let_underscore_must_use)]
3797 let outcome = spawner
3798 .spawn_cancellable(command, args, effective_cwd, stdout_to, kill_rx)
3799 .await;
3800 match outcome {
3801 Ok(result) => {
3802 #[allow(clippy::let_underscore_must_use)]
3803 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3804 process_id,
3805 stdout: result.stdout,
3806 stderr: result.stderr,
3807 exit_code: result.exit_code,
3808 });
3809 }
3810 Err(e) => {
3811 #[allow(clippy::let_underscore_must_use)]
3812 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3813 process_id,
3814 stdout: String::new(),
3815 stderr: e.to_string(),
3816 exit_code: -1,
3817 });
3818 }
3819 }
3820 });
3821 } else {
3822 self.plugin_manager
3823 .read()
3824 .unwrap()
3825 .reject_callback(callback_id, "Async runtime not available".to_string());
3826 }
3827 }
3828
3829 fn handle_kill_host_process(&mut self, process_id: u64) {
3830 if let Some(tx) = self.host_process_handles.remove(&process_id) {
3834 #[allow(clippy::let_underscore_must_use)]
3835 let _ = tx.send(());
3836 tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
3837 } else {
3838 tracing::debug!(
3839 "KillHostProcess: unknown process_id={} (already exited?)",
3840 process_id
3841 );
3842 }
3843 }
3844
3845 fn handle_set_authority(&mut self, payload: serde_json::Value) {
3846 match serde_json::from_value::<crate::services::authority::AuthorityPayload>(payload) {
3849 Ok(parsed) => {
3850 let trust = std::sync::Arc::clone(&self.authority().workspace_trust);
3853 let env = std::sync::Arc::clone(&self.authority().env_provider);
3854 let spec = crate::services::authority::SessionAuthoritySpec::Plugin(parsed.clone());
3860 match crate::services::authority::Authority::from_plugin_payload(parsed, trust, env)
3861 {
3862 Ok(auth) => {
3863 tracing::info!("Plugin installed new authority");
3864 self.active_window_mut().authority_spec = spec;
3865 self.install_authority(auth);
3866 }
3867 Err(e) => {
3868 tracing::warn!("setAuthority: invalid payload: {}", e);
3869 self.set_status_message(format!("setAuthority rejected: {}", e));
3870 }
3871 }
3872 }
3873 Err(e) => {
3874 tracing::warn!("setAuthority: failed to parse payload: {}", e);
3875 self.set_status_message(format!("setAuthority rejected: {}", e));
3876 }
3877 }
3878 }
3879
3880 pub(crate) fn reconnect_dormant_session_if_needed(&mut self, window_id: fresh_core::WindowId) {
3888 if self.session_keepalives.contains_key(&window_id) {
3893 return;
3894 }
3895 self.start_remote_reconnect(window_id);
3896 }
3897
3898 pub(crate) fn force_reconnect_remote_session(&mut self, window_id: fresh_core::WindowId) {
3909 self.start_remote_reconnect(window_id);
3910 }
3911
3912 fn start_remote_reconnect(&mut self, window_id: fresh_core::WindowId) {
3916 let Some(spec) = self
3917 .windows
3918 .get(&window_id)
3919 .map(|w| w.authority_spec.clone())
3920 else {
3921 return;
3922 };
3923 match spec {
3924 crate::services::authority::SessionAuthoritySpec::Local => {}
3925 crate::services::authority::SessionAuthoritySpec::RemoteAgent(agent_spec) => {
3926 let request_id = u64::MAX - window_id.0 as u64;
3930 if self.remote_attach_inflight.contains(&request_id) {
3931 return;
3932 }
3933 if let Some(w) = self.windows.get_mut(&window_id) {
3936 w.remote_reconnect_error = None;
3937 }
3938 self.start_remote_connect(agent_spec, Some(window_id), request_id);
3939 }
3940 crate::services::authority::SessionAuthoritySpec::Plugin(_) => {
3941 tracing::debug!("remote session {window_id}: reattach is plugin-driven (TODO)");
3945 }
3946 }
3947 }
3948
3949 fn handle_attach_remote_agent(&mut self, payload: serde_json::Value, request_id: u64) {
3950 let spec =
3953 match serde_json::from_value::<crate::services::authority::RemoteAgentSpec>(payload) {
3954 Ok(spec) => spec,
3955 Err(e) => {
3956 tracing::warn!("attachRemoteAgent: invalid payload: {}", e);
3957 self.reject_remote_attach(request_id, format!("invalid attach spec: {e}"));
3958 return;
3959 }
3960 };
3961 self.start_remote_connect(spec, None, request_id);
3964 }
3965
3966 pub(crate) fn start_remote_connect(
3973 &mut self,
3974 spec: crate::services::authority::RemoteAgentSpec,
3975 reconnect_window: Option<fresh_core::WindowId>,
3976 request_id: u64,
3977 ) {
3978 let runtime = self.tokio_runtime.clone();
3981 let sender = self.async_bridge.as_ref().map(|b| b.sender());
3982 let (Some(runtime), Some(sender)) = (runtime, sender) else {
3983 self.reject_remote_attach(request_id, "async runtime not available".to_string());
3984 return;
3985 };
3986
3987 self.remote_attach_inflight.insert(request_id);
3992 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
3993 self.remote_attach_cancels.insert(request_id, cancel_tx);
3994
3995 let window_mode = spec.window;
3999 let window_label = spec.label.clone();
4000 let window_command = spec.command.clone();
4001 let trust = std::sync::Arc::new(crate::services::workspace_trust::WorkspaceTrust::new(
4008 None,
4009 self.authority().workspace_trust.level(),
4010 ));
4011 let env = std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive());
4012
4013 use crate::services::authority::RemoteTransportSpec;
4020 let base_env = spec.base_env.clone();
4021 let session_spec =
4024 crate::services::authority::SessionAuthoritySpec::RemoteAgent(spec.clone());
4025 let mode_for = |label: &str| {
4026 if let Some(window_id) = reconnect_window {
4027 crate::services::async_bridge::RemoteAttachMode::Reconnect { window_id }
4028 } else if window_mode {
4029 crate::services::async_bridge::RemoteAttachMode::Window {
4030 label: window_label.clone().unwrap_or_else(|| label.to_string()),
4031 command: window_command.clone(),
4032 }
4033 } else {
4034 crate::services::async_bridge::RemoteAttachMode::Restart
4035 }
4036 };
4037
4038 match spec.transport {
4039 RemoteTransportSpec::KubectlExec { .. } => {
4040 let (target, base_env) = spec.into_kube_target();
4041 let label = target.display();
4042 let workspace = target.workspace.clone().map(std::path::PathBuf::from);
4044 let mode = mode_for(&label);
4045 self.set_status_message(format!("Connecting to {label}…"));
4046 runtime.spawn(async move {
4047 let outcome = crate::services::authority::connect_kube_authority(
4048 target,
4049 base_env,
4050 trust,
4051 env,
4052 Some(cancel_rx),
4053 )
4054 .await;
4055 let msg = match outcome {
4056 Ok((authority, keepalive)) => AsyncMessage::RemoteAttachReady(
4057 crate::services::async_bridge::RemoteAttachReady {
4058 authority,
4059 keepalive: Box::new(keepalive),
4060 working_dir: workspace,
4061 mode,
4062 spec: session_spec,
4063 request_id,
4064 },
4065 ),
4066 Err(e) => AsyncMessage::RemoteAttachFailed {
4067 error: e.to_string(),
4068 request_id,
4069 reconnect_window,
4070 },
4071 };
4072 #[allow(clippy::let_underscore_must_use)]
4073 let _ = sender.send(msg);
4074 });
4075 }
4076 RemoteTransportSpec::Ssh {
4077 user,
4078 host,
4079 port,
4080 identity_file,
4081 remote_path,
4082 extra_args,
4083 } => {
4084 let _ = base_env; let params = crate::services::remote::ConnectionParams {
4086 user: user.clone().filter(|u| !u.is_empty()),
4087 host: host.clone(),
4088 port,
4089 identity_file: identity_file.map(std::path::PathBuf::from),
4090 extra_args,
4091 };
4092 let target = params.ssh_target();
4094 let label = match port {
4095 Some(p) => format!("ssh:{target}:{p}"),
4096 None => format!("ssh:{target}"),
4097 };
4098 let workspace = remote_path.clone().map(std::path::PathBuf::from);
4099 let mode = mode_for(&label);
4100 self.set_status_message(format!("Connecting to {label}…"));
4101 runtime.spawn(async move {
4102 let outcome = crate::services::authority::connect_ssh_authority(
4103 params,
4104 remote_path,
4105 trust,
4106 env,
4107 Some(cancel_rx),
4108 )
4109 .await;
4110 let msg = match outcome {
4111 Ok((authority, keepalive)) => AsyncMessage::RemoteAttachReady(
4112 crate::services::async_bridge::RemoteAttachReady {
4113 authority,
4114 keepalive: Box::new(keepalive),
4115 working_dir: workspace,
4116 mode,
4117 spec: session_spec,
4118 request_id,
4119 },
4120 ),
4121 Err(e) => AsyncMessage::RemoteAttachFailed {
4122 error: e.to_string(),
4123 request_id,
4124 reconnect_window,
4125 },
4126 };
4127 #[allow(clippy::let_underscore_must_use)]
4128 let _ = sender.send(msg);
4129 });
4130 }
4131 }
4132 }
4133
4134 fn handle_set_remote_indicator_state(&mut self, state: serde_json::Value) {
4135 match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(state)
4138 {
4139 Ok(over) => {
4140 self.remote_indicator_override = Some(over);
4141 }
4142 Err(e) => {
4143 tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
4144 self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
4145 }
4146 }
4147 }
4148
4149 fn handle_spawn_process_wait(
4150 &mut self,
4151 process_id: u64,
4152 callback_id: fresh_core::api::JsCallbackId,
4153 ) {
4154 tracing::warn!(
4155 "SpawnProcessWait not fully implemented - process_id={}",
4156 process_id
4157 );
4158 self.plugin_manager.read().unwrap().reject_callback(
4159 callback_id,
4160 format!(
4161 "SpawnProcessWait not yet fully implemented for process_id={}",
4162 process_id
4163 ),
4164 );
4165 }
4166
4167 fn handle_delay(&mut self, callback_id: fresh_core::api::JsCallbackId, duration_ms: u64) {
4168 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
4169 let sender = bridge.sender();
4170 let callback_id_u64 = callback_id.as_u64();
4171 runtime.spawn(async move {
4172 tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
4173 #[allow(clippy::let_underscore_must_use)]
4174 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
4175 fresh_core::api::PluginAsyncMessage::DelayComplete {
4176 callback_id: callback_id_u64,
4177 },
4178 ));
4179 });
4180 } else {
4181 std::thread::sleep(std::time::Duration::from_millis(duration_ms));
4182 self.plugin_manager
4183 .read()
4184 .unwrap()
4185 .resolve_callback(callback_id, "null".to_string());
4186 }
4187 }
4188
4189 fn handle_http_fetch(
4190 &mut self,
4191 url: String,
4192 target_path: std::path::PathBuf,
4193 callback_id: fresh_core::api::JsCallbackId,
4194 ) {
4195 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
4196 let sender = bridge.sender();
4197 let process_id = callback_id.as_u64();
4198
4199 runtime.spawn(async move {
4200 let fetch = tokio::task::spawn_blocking(move || {
4201 crate::services::http::download_to_file(&url, &target_path)
4202 })
4203 .await;
4204
4205 let (stdout, stderr, exit_code) = match fetch {
4206 Ok(Ok(status)) => {
4207 if (200..300).contains(&status) {
4208 (String::new(), String::new(), 0)
4209 } else {
4210 (String::new(), format!("HTTP {}", status), i32::from(status))
4211 }
4212 }
4213 Ok(Err(e)) => (String::new(), e, -1),
4214 Err(e) => (String::new(), format!("fetch task failed: {}", e), -1),
4215 };
4216
4217 #[allow(clippy::let_underscore_must_use)]
4218 let _ = sender.send(AsyncMessage::PluginProcessOutput {
4219 process_id,
4220 stdout,
4221 stderr,
4222 exit_code,
4223 });
4224 });
4225 } else {
4226 self.plugin_manager
4227 .read()
4228 .unwrap()
4229 .reject_callback(callback_id, "Async runtime not available".to_string());
4230 }
4231 }
4232
4233 fn handle_kill_background_process(&mut self, process_id: u64) {
4234 if let Some(handle) = self.background_process_handles.remove(&process_id) {
4235 handle.abort();
4236 tracing::debug!("Killed background process {}", process_id);
4237 }
4238 }
4239
4240 fn handle_create_virtual_buffer(&mut self, name: String, mode: String, read_only: bool) {
4241 let buffer_id =
4242 self.active_window_mut()
4243 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
4244 tracing::info!(
4245 "Created virtual buffer '{}' with mode '{}' (id={:?})",
4246 name,
4247 mode,
4248 buffer_id
4249 );
4250 }
4252
4253 fn handle_set_virtual_buffer_content(
4254 &mut self,
4255 buffer_id: BufferId,
4256 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
4257 ) {
4258 match self.set_virtual_buffer_content(buffer_id, entries) {
4259 Ok(()) => {
4260 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
4261 }
4262 Err(e) => {
4263 tracing::error!("Failed to set virtual buffer content: {}", e);
4264 }
4265 }
4266 }
4267
4268 fn handle_mount_widget_panel(
4269 &mut self,
4270 panel_key: crate::widgets::PanelKey,
4271 buffer_id: BufferId,
4272 spec: fresh_core::api::WidgetSpec,
4273 ) {
4274 let prev = std::collections::HashMap::new();
4279 let prev_focus = String::new();
4280 let panel_width = self.widget_panel_width(buffer_id);
4281 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
4282 let focus_cursor = out.focus_cursor;
4283 self.widget_registry.mount(
4284 panel_key.clone(),
4285 buffer_id,
4286 spec,
4287 out.hits,
4288 out.instance_states,
4289 out.focus_key,
4290 out.tabbable,
4291 );
4292 let entries = out.entries;
4293 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
4294 tracing::error!(
4295 "Failed to render mounted widget panel {} into {:?}: {}",
4296 panel_key,
4297 buffer_id,
4298 e
4299 );
4300 } else {
4301 tracing::debug!(
4302 "Mounted widget panel {} into buffer {:?}",
4303 panel_key,
4304 buffer_id
4305 );
4306 }
4307 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
4308 }
4309
4310 fn handle_update_widget_panel(
4311 &mut self,
4312 panel_key: &crate::widgets::PanelKey,
4313 spec: fresh_core::api::WidgetSpec,
4314 ) {
4315 let prev = match self.widget_registry.instance_states(panel_key) {
4316 Some(s) => s.clone(),
4317 None => {
4318 tracing::debug!(
4319 "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
4320 panel_key
4321 );
4322 return;
4323 }
4324 };
4325 let prev_focus = self
4326 .widget_registry
4327 .focus_key(panel_key)
4328 .map(|s| s.to_string())
4329 .unwrap_or_default();
4330 let buffer_id_for_width = self
4331 .widget_registry
4332 .buffer_and_spec(panel_key)
4333 .map(|(b, _)| b)
4334 .unwrap_or(BufferId(0));
4335 let panel_width = self.widget_panel_width(buffer_id_for_width);
4336 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
4337 let focus_cursor = out.focus_cursor;
4338 let entries = out.entries;
4339 match self.widget_registry.update(
4340 panel_key,
4341 spec,
4342 out.hits,
4343 out.instance_states,
4344 out.focus_key,
4345 out.tabbable,
4346 ) {
4347 Ok(buffer_id) => {
4348 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
4349 tracing::error!("Failed to render updated widget panel {}: {}", panel_key, e);
4350 }
4351 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
4352 }
4353 Err(()) => {
4354 tracing::debug!(
4355 "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
4356 panel_key
4357 );
4358 }
4359 }
4360 }
4361
4362 fn handle_widget_mutate(
4368 &mut self,
4369 panel_key: &crate::widgets::PanelKey,
4370 mutation: fresh_core::api::WidgetMutation,
4371 ) {
4372 use fresh_core::api::WidgetMutation;
4373
4374 if self.widget_registry.get(panel_key).is_none() {
4376 tracing::debug!(
4377 "WidgetMutate for unknown panel {} ignored (not mounted)",
4378 panel_key
4379 );
4380 return;
4381 }
4382
4383 match mutation {
4384 WidgetMutation::SetValue {
4385 widget_key,
4386 value,
4387 cursor_byte,
4388 } => {
4389 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4396 let (scroll, multiline, completions, sel_idx, scroll_off, navigated) =
4404 match panel.instance_states.get(&widget_key) {
4405 Some(crate::widgets::WidgetInstanceState::Text {
4406 editor,
4407 scroll,
4408 completions,
4409 completion_selected_index,
4410 completion_scroll_offset,
4411 completion_navigated,
4412 }) => (
4413 *scroll,
4414 editor.multiline,
4415 completions.clone(),
4416 *completion_selected_index,
4417 *completion_scroll_offset,
4418 *completion_navigated,
4419 ),
4420 _ => (0u32, true, Vec::new(), 0usize, 0u32, false),
4421 };
4422 let mut editor = if multiline {
4423 crate::primitives::text_edit::TextEdit::with_text(&value)
4424 } else {
4425 crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
4426 };
4427 let target = match cursor_byte {
4428 Some(c) if c >= 0 => (c as usize).min(value.len()),
4429 _ => value.len(),
4430 };
4431 editor.set_cursor_from_flat(target);
4432 panel.instance_states.insert(
4433 widget_key,
4434 crate::widgets::WidgetInstanceState::Text {
4435 editor,
4436 scroll,
4437 completions,
4438 completion_selected_index: sel_idx,
4439 completion_scroll_offset: scroll_off,
4440 completion_navigated: navigated,
4441 },
4442 );
4443 }
4444 }
4445 WidgetMutation::SetChecked {
4446 widget_key,
4447 checked,
4448 } => {
4449 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4453 crate::widgets::set_toggle_checked_in_spec(
4454 &mut panel.spec,
4455 &widget_key,
4456 checked,
4457 );
4458 }
4459 }
4460 WidgetMutation::SetSelectedIndex { widget_key, index } => {
4461 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4463 let (prev_scroll, prev_index, prev_item_height, prev_user_scrolled) =
4464 match panel.instance_states.get(&widget_key) {
4465 Some(crate::widgets::WidgetInstanceState::List {
4466 scroll_offset,
4467 selected_index,
4468 item_height,
4469 user_scrolled,
4470 }) => (
4471 *scroll_offset,
4472 *selected_index,
4473 *item_height,
4474 *user_scrolled,
4475 ),
4476 _ => (0, -1, 1, false),
4477 };
4478 let user_scrolled = prev_user_scrolled && index == prev_index;
4484 panel.instance_states.insert(
4485 widget_key,
4486 crate::widgets::WidgetInstanceState::List {
4487 scroll_offset: prev_scroll,
4488 selected_index: index,
4489 item_height: prev_item_height,
4490 user_scrolled,
4491 },
4492 );
4493 }
4494 }
4495 WidgetMutation::SetCompletions { widget_key, items } => {
4496 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4505 if let Some(crate::widgets::WidgetInstanceState::Text {
4506 completions,
4507 completion_selected_index,
4508 completion_scroll_offset,
4509 completion_navigated,
4510 ..
4511 }) = panel.instance_states.get_mut(&widget_key)
4512 {
4513 *completions = items;
4514 *completion_selected_index = 0;
4515 *completion_scroll_offset = 0;
4516 *completion_navigated = false;
4521 }
4522 }
4523 }
4524 WidgetMutation::SetItems {
4525 widget_key,
4526 items,
4527 item_keys,
4528 } => {
4529 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4531 crate::widgets::set_list_items_in_spec(
4532 &mut panel.spec,
4533 &widget_key,
4534 items,
4535 item_keys,
4536 );
4537 }
4538 }
4539 WidgetMutation::SetExpandedKeys { widget_key, keys } => {
4540 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4542 let (prev_scroll, prev_sel) = match panel.instance_states.get(&widget_key) {
4543 Some(crate::widgets::WidgetInstanceState::Tree {
4544 scroll_offset,
4545 selected_index,
4546 ..
4547 }) => (*scroll_offset, *selected_index),
4548 _ => (0, -1),
4549 };
4550 let expanded: std::collections::HashSet<String> = keys.into_iter().collect();
4551 panel.instance_states.insert(
4552 widget_key,
4553 crate::widgets::WidgetInstanceState::Tree {
4554 scroll_offset: prev_scroll,
4555 selected_index: prev_sel,
4556 expanded_keys: expanded,
4557 },
4558 );
4559 }
4560 }
4561 WidgetMutation::SetCheckedKeys {
4562 widget_key,
4563 checked,
4564 keys,
4565 } => {
4566 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4574 crate::widgets::set_tree_checked_keys_in_spec(
4575 &mut panel.spec,
4576 &widget_key,
4577 checked,
4578 &keys,
4579 );
4580 }
4581 }
4582 WidgetMutation::AppendTreeNodes {
4583 widget_key,
4584 new_nodes,
4585 new_item_keys,
4586 } => {
4587 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4588 crate::widgets::append_tree_nodes_in_spec(
4589 &mut panel.spec,
4590 &widget_key,
4591 new_nodes,
4592 new_item_keys,
4593 );
4594 }
4595 }
4596 WidgetMutation::SetRawEntries {
4597 widget_key,
4598 entries,
4599 } => {
4600 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4601 crate::widgets::set_raw_entries_in_spec(&mut panel.spec, &widget_key, entries);
4602 }
4603 }
4604 WidgetMutation::SetFocusKey { widget_key } => {
4605 self.widget_registry.set_focus_key(panel_key, widget_key);
4610 }
4611 }
4612
4613 self.rerender_widget_panel(panel_key);
4617 }
4618
4619 fn handle_unmount_widget_panel(&mut self, panel_key: &crate::widgets::PanelKey) {
4620 match self.widget_registry.unmount(panel_key) {
4621 Some(buffer_id) => {
4622 tracing::debug!(
4623 "Unmounted widget panel {} (was rendering into {:?})",
4624 panel_key,
4625 buffer_id
4626 );
4627 }
4632 None => {
4633 tracing::debug!("UnmountWidgetPanel for unknown panel {} ignored", panel_key);
4634 }
4635 }
4636 }
4637
4638 fn handle_mount_floating_widget(
4639 &mut self,
4640 panel_key: crate::widgets::PanelKey,
4641 spec: fresh_core::api::WidgetSpec,
4642 width_pct: u8,
4643 height_pct: u8,
4644 as_dock: bool,
4645 focus_marker: bool,
4646 ) {
4647 let width_pct = width_pct.clamp(1, 100);
4648 let height_pct = height_pct.clamp(1, 100);
4649 let slot = if as_dock {
4652 super::PanelSlot::Dock
4653 } else {
4654 super::PanelSlot::Floating
4655 };
4656 let buffer_id = slot.buffer_id();
4657 if !as_dock && self.dock.as_ref().is_some_and(|f| f.focused) {
4664 self.blur_floating_panel(super::PanelSlot::Dock);
4665 }
4666 let placement = if as_dock {
4667 let width = self
4668 .dock_width
4669 .unwrap_or(32)
4670 .clamp(10, self.terminal_width.max(20).saturating_sub(20).max(10));
4671 super::PanelPlacement::LeftDock { width_cols: width }
4672 } else {
4673 super::PanelPlacement::Centered
4674 };
4675 if let Some(existing) = self.panel_opt_mut(slot).take() {
4676 if existing.panel_key != panel_key {
4677 let _ = self.widget_registry.unmount(&existing.panel_key);
4678 }
4679 }
4680 *self.panel_opt_mut(slot) = Some(FloatingWidgetState {
4681 panel_key: panel_key.clone(),
4682 width_pct,
4683 height_pct,
4684 placement,
4685 focused: true,
4686 entries: Vec::new(),
4687 focus_cursor: None,
4688 embeds: Vec::new(),
4689 overlays: Vec::new(),
4690 scroll_regions: Vec::new(),
4691 scrollbar_tracks: Vec::new(),
4692 scrollbar_mouse: Default::default(),
4693 scrollbar_drag_key: None,
4694 last_inner_rect: None,
4695 scrollbar_hover_zones: Vec::new(),
4696 scrollbar_zone_hovered: false,
4697 fullscreen: false,
4698 focus_marker,
4699 });
4700 let prev = std::collections::HashMap::new();
4701 let prev_focus = String::new();
4702 let panel_width = self.floating_panel_inner_width(slot);
4703 let out = super::widget_runtime::render_floating_spec(
4704 focus_marker,
4705 &spec,
4706 &prev,
4707 &prev_focus,
4708 panel_width,
4709 );
4710 let focus_cursor = out.focus_cursor;
4711 let entries = out.entries;
4712 let embeds = out.embeds;
4713 let overlays = out.overlays;
4714 let scroll_regions = out.scroll_regions;
4715 self.widget_registry.mount(
4716 panel_key.clone(),
4717 buffer_id,
4718 spec,
4719 out.hits,
4720 out.instance_states,
4721 out.focus_key,
4722 out.tabbable,
4723 );
4724 if let Some(fwp) = self.panel_mut(slot) {
4725 fwp.entries = entries;
4726 fwp.focus_cursor = focus_cursor;
4727 fwp.embeds = embeds;
4728 fwp.overlays = overlays;
4729 fwp.scroll_regions = scroll_regions;
4730 }
4731 tracing::debug!(
4732 "Mounted floating widget panel {} ({}%x{}%)",
4733 panel_key,
4734 width_pct,
4735 height_pct
4736 );
4737
4738 if as_dock {
4743 self.relayout();
4744 }
4745 }
4746
4747 fn handle_update_floating_widget(
4748 &mut self,
4749 panel_key: &crate::widgets::PanelKey,
4750 spec: fresh_core::api::WidgetSpec,
4751 ) {
4752 let Some(slot) = self.slot_of_panel(panel_key) else {
4753 tracing::debug!(
4754 "UpdateFloatingWidget for unknown / mismatched panel {} ignored",
4755 panel_key
4756 );
4757 return;
4758 };
4759 let prev = self
4760 .widget_registry
4761 .instance_states(panel_key)
4762 .cloned()
4763 .unwrap_or_default();
4764 let prev_focus = self
4765 .widget_registry
4766 .focus_key(panel_key)
4767 .map(|s| s.to_string())
4768 .unwrap_or_default();
4769 let panel_width = self.floating_panel_inner_width(slot);
4770 let focus_marker = self.panel(slot).map(|f| f.focus_marker).unwrap_or(false);
4771 let out = super::widget_runtime::render_floating_spec(
4772 focus_marker,
4773 &spec,
4774 &prev,
4775 &prev_focus,
4776 panel_width,
4777 );
4778 let focus_cursor = out.focus_cursor;
4779 let entries = out.entries;
4780 let embeds = out.embeds;
4781 let overlays = out.overlays;
4782 let scroll_regions = out.scroll_regions;
4783 if self
4784 .widget_registry
4785 .update(
4786 panel_key,
4787 spec,
4788 out.hits,
4789 out.instance_states,
4790 out.focus_key,
4791 out.tabbable,
4792 )
4793 .is_err()
4794 {
4795 tracing::debug!(
4796 "UpdateFloatingWidget for unknown panel {} ignored (not in registry)",
4797 panel_key
4798 );
4799 return;
4800 }
4801 if let Some(fwp) = self.panel_mut(slot) {
4802 fwp.entries = entries;
4803 fwp.focus_cursor = focus_cursor;
4804 fwp.embeds = embeds;
4805 fwp.overlays = overlays;
4806 fwp.scroll_regions = scroll_regions;
4807 }
4808 }
4809
4810 fn handle_unmount_floating_widget(&mut self, panel_key: &crate::widgets::PanelKey) {
4811 let Some(slot) = self.slot_of_panel(panel_key) else {
4812 tracing::debug!(
4813 "UnmountFloatingWidget for unknown / mismatched panel {} ignored",
4814 panel_key
4815 );
4816 return;
4817 };
4818 *self.panel_opt_mut(slot) = None;
4819 let _ = self.widget_registry.unmount(panel_key);
4820 if slot == super::PanelSlot::Dock {
4831 self.request_full_redraw();
4832 }
4833 self.relayout();
4849 tracing::debug!("Unmounted floating widget panel {}", panel_key);
4850 }
4851
4852 fn handle_floating_panel_control(
4855 &mut self,
4856 panel_key: &crate::widgets::PanelKey,
4857 op: &str,
4858 arg: f64,
4859 ) {
4860 let Some(slot) = self.slot_of_panel(panel_key) else {
4861 tracing::warn!("FloatingPanelControl for unknown/mismatched panel {panel_key} ignored");
4862 return;
4863 };
4864 if op == "blur" {
4867 self.blur_floating_panel(slot);
4868 return;
4869 }
4870 let max_cols = self.terminal_width.max(20).saturating_sub(20).max(10);
4875 let persisted = self.dock_width;
4876 let Some(fwp) = self.panel_mut(slot) else {
4877 return;
4878 };
4879 let geometry_changed = match op {
4882 "dock" => {
4883 let requested = persisted.unwrap_or(arg as u16);
4884 let width_cols = requested.clamp(10, max_cols);
4885 fwp.placement = super::PanelPlacement::LeftDock { width_cols };
4886 fwp.focused = true;
4887 true
4888 }
4889 "dock_width" => {
4895 if let super::PanelPlacement::LeftDock { .. } = fwp.placement {
4896 let requested = persisted.unwrap_or(arg as u16);
4897 let width_cols = requested.clamp(10, max_cols);
4898 fwp.placement = super::PanelPlacement::LeftDock { width_cols };
4899 true
4900 } else {
4901 false
4902 }
4903 }
4904 "center" => {
4905 fwp.placement = super::PanelPlacement::Centered;
4906 fwp.focused = true;
4907 true
4908 }
4909 "anchor" => {
4915 let packed = arg.max(0.0) as u64;
4916 let x = (packed & 0xFFFF) as u16;
4917 let y = ((packed >> 16) & 0xFFFF) as u16;
4918 fwp.placement = super::PanelPlacement::Anchored { x, y };
4919 fwp.focused = true;
4920 fwp.fullscreen = false;
4921 false
4922 }
4923 "focus" => {
4924 fwp.focused = true;
4925 false
4926 }
4927 "fullscreen" => {
4933 fwp.fullscreen = arg != 0.0;
4934 false
4935 }
4936 other => {
4937 tracing::warn!("FloatingPanelControl: unknown op {other:?}");
4938 false
4939 }
4940 };
4941 if geometry_changed {
4945 self.relayout();
4946 }
4947 }
4948
4949 fn handle_get_text_properties_at_cursor(&self, buffer_id: BufferId) {
4950 if let Some(state) = self
4951 .windows
4952 .get(&self.active_window)
4953 .map(|w| &w.buffers)
4954 .expect("active window present")
4955 .get(&buffer_id)
4956 {
4957 let cursor_pos = self
4958 .windows
4959 .get(&self.active_window)
4960 .and_then(|w| w.buffers.splits())
4961 .map(|(_, vs)| vs)
4962 .expect("active window must have a populated split layout")
4963 .values()
4964 .find_map(|vs| vs.buffer_state(buffer_id))
4965 .map(|bs| bs.cursors.primary().position)
4966 .unwrap_or(0);
4967 let properties = state.text_properties.get_at(cursor_pos);
4968 tracing::debug!(
4969 "Text properties at cursor in {:?}: {} properties found",
4970 buffer_id,
4971 properties.len()
4972 );
4973 }
4975 }
4976
4977 fn handle_set_context(&mut self, name: String, active: bool) {
4978 if active {
4979 self.active_window_mut()
4980 .active_custom_contexts
4981 .insert(name.clone());
4982 tracing::debug!("Set custom context: {}", name);
4983 } else {
4984 self.active_window_mut()
4985 .active_custom_contexts
4986 .remove(&name);
4987 tracing::debug!("Unset custom context: {}", name);
4988 }
4989 }
4990
4991 fn handle_disable_lsp_for_language(&mut self, language: String) {
4992 tracing::info!("Disabling LSP for language: {}", language);
4993 let __active_id = self.active_window;
4994 if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
4995 lsp.shutdown_server(&language);
4996 tracing::info!("Stopped LSP server for {}", language);
4997 }
4998 if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
4999 for c in lsp_configs.as_mut_slice() {
5000 c.enabled = false;
5001 c.auto_start = false;
5002 }
5003 tracing::info!("Disabled LSP config for {}", language);
5004 }
5005 if let Err(e) = self.save_config() {
5006 tracing::error!("Failed to save config: {}", e);
5007 self.active_window_mut().status_message = Some(format!(
5008 "LSP disabled for {} (config save failed)",
5009 language
5010 ));
5011 } else {
5012 self.active_window_mut().status_message =
5013 Some(format!("LSP disabled for {}", language));
5014 }
5015 self.active_window_mut().warning_domains.lsp.clear();
5016 }
5017
5018 fn handle_restart_lsp_for_language(&mut self, language: String) {
5019 tracing::info!("Plugin restarting LSP for language: {}", language);
5020 let file_path = self
5021 .active_window()
5022 .buffer_metadata
5023 .get(&self.active_buffer())
5024 .and_then(|meta| meta.file_path().cloned());
5025 let __active_id = self.active_window;
5026 let success = if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
5027 let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
5028 self.active_window_mut().status_message = Some(msg);
5029 ok
5030 } else {
5031 self.active_window_mut().status_message = Some("No LSP manager available".to_string());
5032 false
5033 };
5034 if success {
5035 self.reopen_buffers_for_language(&language);
5036 }
5037 }
5038
5039 fn handle_set_lsp_root_uri(&mut self, language: String, uri: String) {
5040 tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
5041 match uri.parse::<lsp_types::Uri>() {
5042 Ok(parsed_uri) => {
5043 let __active_id = self.active_window;
5044 if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
5045 let restarted = lsp.set_language_root_uri(&language, parsed_uri);
5046 if restarted {
5047 self.active_window_mut().status_message = Some(format!(
5048 "LSP root updated for {} (restarting server)",
5049 language
5050 ));
5051 } else {
5052 self.active_window_mut().status_message =
5053 Some(format!("LSP root set for {}", language));
5054 }
5055 }
5056 }
5057 Err(e) => {
5058 tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
5059 self.active_window_mut().status_message =
5060 Some(format!("Invalid LSP root URI: {}", e));
5061 }
5062 }
5063 }
5064
5065 fn handle_create_scroll_sync_group(
5066 &mut self,
5067 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5068 left_split: SplitId,
5069 right_split: SplitId,
5070 ) {
5071 let success = self
5072 .active_window_mut()
5073 .scroll_sync_manager
5074 .create_group_with_id(group_id, left_split, right_split);
5075 if success {
5076 tracing::debug!(
5077 "Created scroll sync group {} for splits {:?} and {:?}",
5078 group_id,
5079 left_split,
5080 right_split
5081 );
5082 } else {
5083 tracing::warn!(
5084 "Failed to create scroll sync group {} (ID already exists)",
5085 group_id
5086 );
5087 }
5088 }
5089
5090 fn handle_set_scroll_sync_anchors(
5091 &mut self,
5092 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5093 anchors: Vec<(usize, usize)>,
5094 ) {
5095 use crate::view::scroll_sync::SyncAnchor;
5096 let anchor_count = anchors.len();
5097 let sync_anchors: Vec<SyncAnchor> = anchors
5098 .into_iter()
5099 .map(|(left_line, right_line)| SyncAnchor {
5100 left_line,
5101 right_line,
5102 })
5103 .collect();
5104 self.active_window_mut()
5105 .scroll_sync_manager
5106 .set_anchors(group_id, sync_anchors);
5107 tracing::debug!(
5108 "Set {} anchors for scroll sync group {}",
5109 anchor_count,
5110 group_id
5111 );
5112 }
5113
5114 fn handle_remove_scroll_sync_group(
5115 &mut self,
5116 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5117 ) {
5118 if self
5119 .active_window_mut()
5120 .scroll_sync_manager
5121 .remove_group(group_id)
5122 {
5123 tracing::debug!("Removed scroll sync group {}", group_id);
5124 } else {
5125 tracing::warn!("Scroll sync group {} not found", group_id);
5126 }
5127 }
5128
5129 fn handle_create_buffer_group(
5130 &mut self,
5131 name: String,
5132 mode: String,
5133 layout_json: String,
5134 request_id: Option<u64>,
5135 ) {
5136 match self.create_buffer_group(name, mode, layout_json) {
5137 Ok(result) => {
5138 if let Some(req_id) = request_id {
5139 let json = serde_json::to_string(&result).unwrap_or_default();
5140 self.plugin_manager
5141 .read()
5142 .unwrap()
5143 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
5144 }
5145 }
5146 Err(e) => {
5147 tracing::error!("Failed to create buffer group: {}", e);
5148 }
5149 }
5150 }
5151
5152 fn handle_send_terminal_input(
5153 &mut self,
5154 terminal_id: crate::services::terminal::TerminalId,
5155 data: String,
5156 ) {
5157 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
5158 handle.write(data.as_bytes());
5159 tracing::trace!(
5160 "Plugin sent {} bytes to terminal {:?}",
5161 data.len(),
5162 terminal_id
5163 );
5164 } else {
5165 tracing::warn!(
5166 "Plugin tried to send input to non-existent terminal {:?}",
5167 terminal_id
5168 );
5169 }
5170 }
5171
5172 fn handle_close_terminal(&mut self, terminal_id: crate::services::terminal::TerminalId) {
5173 let buffer_to_close = self
5174 .active_window()
5175 .terminal_buffers
5176 .iter()
5177 .find(|(_, tb)| tb.terminal_id == terminal_id)
5178 .map(|(&bid, _)| bid);
5179 if let Some(buffer_id) = buffer_to_close {
5180 if let Err(e) = self.close_buffer(buffer_id) {
5181 tracing::warn!("Failed to close terminal buffer: {}", e);
5182 }
5183 tracing::info!("Plugin closed terminal {:?}", terminal_id);
5184 } else {
5185 self.active_window_mut().terminal_manager.close(terminal_id);
5186 tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
5187 }
5188 }
5189
5190 fn handle_signal_window(&mut self, id: fresh_core::WindowId, signal: &str) {
5198 let Some(window) = self.windows.get_mut(&id) else {
5199 tracing::warn!("Plugin SignalWindow targeted unknown window {:?}", id);
5200 return;
5201 };
5202 let results = window.process_groups.signal_all(signal);
5203 for (entry, result) in results {
5204 match result {
5205 Ok(true) => tracing::info!(
5206 "SignalWindow {:?}: {} → pid {} ({})",
5207 id,
5208 signal,
5209 entry.leader_pid,
5210 entry.label
5211 ),
5212 Ok(false) => tracing::debug!(
5213 "SignalWindow {:?}: pid {} ({}) already exited",
5214 id,
5215 entry.leader_pid,
5216 entry.label
5217 ),
5218 Err(e) => tracing::warn!(
5219 "SignalWindow {:?}: pid {} ({}): {}",
5220 id,
5221 entry.leader_pid,
5222 entry.label,
5223 e
5224 ),
5225 }
5226 }
5227 }
5228}
5229
5230fn clamp_buffer_text_range(start: usize, end: usize, len: usize) -> (usize, usize) {
5241 let end = end.min(len);
5242 let start = start.min(end);
5243 (start, end)
5244}
5245
5246#[cfg(test)]
5247mod tests {
5248 use tokio::io::{AsyncReadExt, BufReader};
5261 use tokio::process::Command as TokioCommand;
5262 use tokio::time::{timeout, Duration};
5263
5264 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5275 async fn kill_via_oneshot_terminates_long_running_child() {
5276 let mut cmd = TokioCommand::new("sleep");
5277 cmd.args(["30"]);
5278 cmd.stdout(std::process::Stdio::piped());
5279 cmd.stderr(std::process::Stdio::piped());
5280
5281 let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
5282 let pid = child.id().expect("child has a pid");
5283
5284 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
5285 let stdout_pipe = child.stdout.take();
5286 let stderr_pipe = child.stderr.take();
5287
5288 let stdout_fut = async {
5289 let mut buf = String::new();
5290 if let Some(s) = stdout_pipe {
5291 #[allow(clippy::let_underscore_must_use)]
5292 let _ = BufReader::new(s).read_to_string(&mut buf).await;
5293 }
5294 buf
5295 };
5296 let stderr_fut = async {
5297 let mut buf = String::new();
5298 if let Some(s) = stderr_pipe {
5299 #[allow(clippy::let_underscore_must_use)]
5300 let _ = BufReader::new(s).read_to_string(&mut buf).await;
5301 }
5302 buf
5303 };
5304 let wait_fut = async {
5305 tokio::select! {
5306 status = child.wait() => {
5307 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
5308 }
5309 _ = &mut kill_rx => {
5310 #[allow(clippy::let_underscore_must_use)]
5311 let _ = child.start_kill();
5312 child
5313 .wait()
5314 .await
5315 .map(|s| s.code().unwrap_or(-1))
5316 .unwrap_or(-1)
5317 }
5318 }
5319 };
5320
5321 tokio::time::sleep(Duration::from_millis(50)).await;
5326 kill_tx.send(()).expect("kill channel send");
5327
5328 let result = timeout(Duration::from_secs(5), async {
5329 tokio::join!(stdout_fut, stderr_fut, wait_fut)
5330 })
5331 .await;
5332
5333 let (_stdout, _stderr, exit_code) = result.expect(
5334 "kill path must resolve within 5s — if this times out the \
5335 select! arm order or kill-then-wait logic is broken",
5336 );
5337 assert_ne!(
5349 exit_code, 0,
5350 "killed child must exit non-success (got 0 — did the \
5351 kill arm fire too late, or did sleep somehow complete?)"
5352 );
5353
5354 #[cfg(unix)]
5363 {
5364 let still_alive = std::process::Command::new("kill")
5365 .args(["-0", &pid.to_string()])
5366 .status()
5367 .map(|s| s.success())
5368 .unwrap_or(false);
5369 assert!(
5370 !still_alive,
5371 "process {pid} must be reaped after wait() — a still-\
5372 alive check means the kill path leaked the child"
5373 );
5374 }
5375 #[cfg(not(unix))]
5376 {
5377 let _ = pid;
5380 }
5381 }
5382
5383 use super::clamp_buffer_text_range;
5384
5385 #[test]
5386 fn clamp_text_range_passes_through_in_bounds() {
5387 assert_eq!(clamp_buffer_text_range(0, 165, 165), (0, 165));
5388 assert_eq!(clamp_buffer_text_range(10, 50, 165), (10, 50));
5389 }
5390
5391 #[test]
5397 fn clamp_text_range_clamps_stale_end_past_buffer() {
5398 assert_eq!(clamp_buffer_text_range(0, 165_003, 165_002), (0, 165_002));
5399 }
5400
5401 #[test]
5402 fn clamp_text_range_pins_overlarge_start_to_empty() {
5403 assert_eq!(clamp_buffer_text_range(200, 250, 165), (165, 165));
5405 }
5406}
5407
5408impl Window {
5409 #[cfg(feature = "plugins")]
5424 pub(crate) fn populate_plugin_state_snapshot(
5425 &mut self,
5426 snapshot: &mut fresh_core::api::EditorStateSnapshot,
5427 ) {
5428 use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
5429
5430 let current_gen = self.resources.grammar_registry.catalog_gen();
5436 if snapshot.last_grammar_gen != current_gen {
5437 snapshot.available_grammars = self
5438 .resources
5439 .grammar_registry
5440 .available_grammar_info()
5441 .into_iter()
5442 .map(|g| fresh_core::api::GrammarInfoSnapshot {
5443 name: g.name,
5444 source: g.source.to_string(),
5445 file_extensions: g.file_extensions,
5446 short_name: g.short_name,
5447 })
5448 .collect();
5449 snapshot.last_grammar_gen = current_gen;
5450 }
5451
5452 snapshot.active_buffer_id = self.active_buffer();
5453
5454 snapshot.macros = {
5460 let macros = &self.macros;
5461 macros
5462 .keys_sorted()
5463 .into_iter()
5464 .filter_map(|key| {
5465 macros
5466 .get(key)
5467 .map(|actions| fresh_core::api::MacroSnapshot {
5468 register: key.to_string(),
5469 steps: actions.iter().map(|a| a.to_action_spec()).collect(),
5470 })
5471 })
5472 .collect()
5473 };
5474
5475 let (mgr_ref, vs_ref) = self
5476 .buffers
5477 .splits()
5478 .expect("active window must have a populated split layout");
5479 let active_split = mgr_ref.active_split();
5480 snapshot.active_split_id = active_split.0 .0;
5481
5482 snapshot.buffers.clear();
5484 snapshot.buffer_saved_diffs.clear();
5485 snapshot.buffer_cursor_positions.clear();
5486 snapshot.buffer_text_properties.clear();
5487
5488 let active_vs_opt = vs_ref.get(&active_split);
5489 for (buffer_id, state) in &self.buffers {
5490 let is_virtual = self
5491 .buffer_metadata
5492 .get(buffer_id)
5493 .map(|m| m.is_virtual())
5494 .unwrap_or(false);
5495 let view_mode = active_vs_opt
5500 .and_then(|vs| vs.buffer_state(*buffer_id))
5501 .map(|bs| match bs.view_mode {
5502 crate::state::ViewMode::Source => "source",
5503 crate::state::ViewMode::PageView => "compose",
5504 })
5505 .unwrap_or("source");
5506 let compose_width = active_vs_opt
5507 .and_then(|vs| vs.buffer_state(*buffer_id))
5508 .and_then(|bs| bs.compose_width);
5509 let is_composing_in_any_split = vs_ref.values().any(|vs| {
5510 vs.buffer_state(*buffer_id)
5511 .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
5512 .unwrap_or(false)
5513 });
5514 let is_preview = self.is_buffer_preview(*buffer_id);
5515 let splits: Vec<fresh_core::SplitId> = mgr_ref
5521 .splits_for_buffer(*buffer_id)
5522 .into_iter()
5523 .map(|leaf_id| leaf_id.0)
5524 .collect();
5525 let buffer_info = BufferInfo {
5526 id: *buffer_id,
5527 path: state.buffer.file_path().map(|p| p.to_path_buf()),
5528 modified: state.buffer.is_modified(),
5529 length: state.buffer.len(),
5530 is_virtual,
5531 editing_disabled: state.editing_disabled,
5532 view_mode: view_mode.to_string(),
5533 is_composing_in_any_split,
5534 compose_width,
5535 language: state.language.clone(),
5536 is_preview,
5537 splits,
5538 };
5539 snapshot.buffers.insert(*buffer_id, buffer_info);
5540
5541 let diff = {
5542 let diff = state.buffer.diff_since_saved();
5543 BufferSavedDiff {
5544 equal: diff.equal,
5545 byte_ranges: diff.byte_ranges.clone(),
5546 }
5547 };
5548 snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
5549
5550 let is_hidden = self
5559 .buffer_metadata
5560 .get(buffer_id)
5561 .is_some_and(|m| m.hidden_from_tabs);
5562 let source_split = vs_ref.iter().find(|(split_id, vs)| {
5563 vs.keyed_states.contains_key(buffer_id)
5564 && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
5565 });
5566 let cursor_pos = source_split
5567 .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
5568 .map(|bs| bs.cursors.primary().position)
5569 .unwrap_or(0);
5570 tracing::trace!(
5571 "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
5572 buffer_id,
5573 cursor_pos,
5574 source_split.map(|(id, _)| *id),
5575 );
5576 snapshot
5577 .buffer_cursor_positions
5578 .insert(*buffer_id, cursor_pos);
5579
5580 if !state.text_properties.is_empty() {
5582 snapshot
5583 .buffer_text_properties
5584 .insert(*buffer_id, state.text_properties.all().to_vec());
5585 }
5586 }
5587
5588 let active_buf_id = snapshot.active_buffer_id;
5599 let active_split_id = self.effective_active_pair().0;
5600 self.buffers
5601 .with_all_mut(|buffers_mut, mgr, vs_map| {
5602 let _ = mgr; if let Some(active_vs) = vs_map.get(&active_split_id) {
5604 let active_cursors = &active_vs.cursors;
5606 let primary = active_cursors.primary();
5607 let primary_position = primary.position;
5608 let primary_selection = primary.selection_range();
5609
5610 let line_of = |offset: usize| -> Option<usize> {
5616 buffers_mut.get(&active_buf_id).and_then(|state| {
5617 if state.buffer.line_count().is_some() {
5618 Some(state.buffer.get_line_number(offset))
5619 } else {
5620 None
5621 }
5622 })
5623 };
5624
5625 snapshot.primary_cursor = Some(CursorInfo {
5626 position: primary_position,
5627 selection: primary_selection.clone(),
5628 line: line_of(primary_position),
5629 });
5630
5631 snapshot.primary_cursor_line = Some(
5636 buffers_mut
5637 .get(&active_buf_id)
5638 .map(|s| s.primary_cursor_line_number.value() as u32)
5639 .unwrap_or(0),
5640 );
5641
5642 snapshot.all_cursors = active_cursors
5643 .iter()
5644 .map(|(_, cursor)| CursorInfo {
5645 position: cursor.position,
5646 selection: cursor.selection_range(),
5647 line: line_of(cursor.position),
5648 })
5649 .collect();
5650
5651 if let Some(range) = primary_selection {
5653 if let Some(active_state) = buffers_mut.get_mut(&active_buf_id) {
5654 snapshot.selected_text =
5655 Some(active_state.get_text_range(range.start, range.end));
5656 }
5657 }
5658
5659 let top_line = buffers_mut.get(&active_buf_id).and_then(|state| {
5661 if state.buffer.line_count().is_some() {
5662 Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
5663 } else {
5664 None
5665 }
5666 });
5667 snapshot.viewport = Some(ViewportInfo {
5668 top_byte: active_vs.viewport.top_byte,
5669 top_line,
5670 left_column: active_vs.viewport.left_column,
5671 width: active_vs.viewport.width,
5672 height: active_vs.viewport.height,
5673 });
5674 } else {
5675 snapshot.primary_cursor = None;
5676 snapshot.primary_cursor_line = None;
5677 snapshot.all_cursors.clear();
5678 snapshot.viewport = None;
5679 snapshot.selected_text = None;
5680 }
5681
5682 snapshot.splits.clear();
5684 for (leaf_id, vs) in vs_map.iter() {
5685 let buf_id = vs.active_buffer;
5686 let top_line = buffers_mut.get(&buf_id).and_then(|state| {
5687 if state.buffer.line_count().is_some() {
5688 Some(state.buffer.get_line_number(vs.viewport.top_byte))
5689 } else {
5690 None
5691 }
5692 });
5693 snapshot.splits.push(fresh_core::api::SplitSnapshot {
5694 split_id: leaf_id.0 .0,
5695 buffer_id: buf_id,
5696 viewport: ViewportInfo {
5697 top_byte: vs.viewport.top_byte,
5698 top_line,
5699 left_column: vs.viewport.left_column,
5700 width: vs.viewport.width,
5701 height: vs.viewport.height,
5702 },
5703 });
5704 }
5705 })
5706 .expect("active window must have a populated split layout");
5707
5708 snapshot.active_session_plugin_states = self.plugin_state.clone();
5714 snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
5719 snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
5720
5721 snapshot.editor_mode = self.editor_mode.clone();
5723
5724 let active_split_id_u64 = active_split_id.0 .0;
5729 let split_changed = snapshot.plugin_view_states_split != active_split_id_u64;
5730 if split_changed {
5731 snapshot.plugin_view_states.clear();
5732 snapshot.plugin_view_states_split = active_split_id_u64;
5733 }
5734
5735 {
5737 let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
5738 snapshot
5739 .plugin_view_states
5740 .retain(|bid, _| open_bids.contains(bid));
5741 }
5742
5743 if let Some(vs_map) = self.buffers.split_view_states() {
5745 if let Some(active_vs) = vs_map.get(&active_split_id) {
5746 for (buffer_id, buf_state) in &active_vs.keyed_states {
5747 if !buf_state.plugin_state.is_empty() {
5748 let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
5749 for (key, value) in &buf_state.plugin_state {
5750 entry.entry(key.clone()).or_insert_with(|| value.clone());
5751 }
5752 }
5753 }
5754 }
5755 }
5756
5757 snapshot.has_active_search = self.search_state.is_some();
5759 }
5760}
5761
5762