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 mut session_infos: Vec<fresh_core::api::WindowInfo> = self
183 .windows
184 .values()
185 .map(|s| {
186 let slot = s.plugin_state.get("orchestrator");
187 let project_path = slot
194 .and_then(|m| m.get("project_path"))
195 .and_then(|v| v.as_str())
196 .filter(|p| !p.is_empty())
197 .map(std::path::PathBuf::from)
198 .unwrap_or_else(|| s.root.clone());
199 let shared_worktree = slot
200 .and_then(|m| m.get("shared_worktree"))
201 .and_then(|v| v.as_bool())
202 .unwrap_or(false);
203 fresh_core::api::WindowInfo {
204 id: s.id,
205 label: s.label.clone(),
206 root: normalize_plugin_path(s.root.clone()),
207 project_path: normalize_plugin_path(project_path),
208 shared_worktree,
209 }
210 })
211 .collect();
212 session_infos.sort_by_key(|s| s.id.0);
213 snapshot.windows = session_infos;
214 snapshot.active_window_id = self.active_window;
215
216 if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
225 let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
226 self.config_cached_json = Arc::new(json);
227 self.config_snapshot_anchor = Arc::clone(&self.config);
228 }
229 snapshot.config = Arc::clone(&self.config_cached_json);
230
231 snapshot.user_config = Arc::clone(&self.user_config_raw);
234
235 for (plugin_name, state_map) in &self.plugin_global_state {
238 let entry = snapshot
239 .plugin_global_states
240 .entry(plugin_name.clone())
241 .or_default();
242 for (key, value) in state_map {
243 entry.entry(key.clone()).or_insert_with(|| value.clone());
244 }
245 }
246 }
247
248 pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
250 match command {
251 PluginCommand::InsertText {
253 buffer_id,
254 position,
255 text,
256 } => {
257 self.handle_insert_text(buffer_id, position, text);
258 }
259 PluginCommand::DeleteRange { buffer_id, range } => {
260 self.handle_delete_range(buffer_id, range);
261 }
262 PluginCommand::InsertAtCursor { text } => {
263 self.handle_insert_at_cursor(text);
264 }
265 PluginCommand::DeleteSelection => {
266 self.handle_delete_selection();
267 }
268
269 PluginCommand::AddOverlay {
271 buffer_id,
272 namespace,
273 range,
274 options,
275 } => {
276 self.handle_add_overlay(buffer_id, namespace, range, options);
277 }
278 PluginCommand::RemoveOverlay { buffer_id, handle } => {
279 self.handle_remove_overlay(buffer_id, handle);
280 }
281 PluginCommand::ClearAllOverlays { buffer_id } => {
282 self.handle_clear_all_overlays(buffer_id);
283 }
284 PluginCommand::ClearNamespace {
285 buffer_id,
286 namespace,
287 } => {
288 self.handle_clear_namespace(buffer_id, namespace);
289 }
290 PluginCommand::ClearOverlaysInRange {
291 buffer_id,
292 start,
293 end,
294 } => {
295 self.handle_clear_overlays_in_range(buffer_id, start, end);
296 }
297 PluginCommand::ClearOverlaysInRangeForNamespace {
298 buffer_id,
299 namespace,
300 start,
301 end,
302 } => {
303 self.handle_clear_overlays_in_range_for_namespace(buffer_id, namespace, start, end);
304 }
305
306 PluginCommand::AddVirtualText {
308 buffer_id,
309 virtual_text_id,
310 position,
311 text,
312 color,
313 use_bg,
314 before,
315 } => {
316 self.handle_add_virtual_text(
317 buffer_id,
318 virtual_text_id,
319 position,
320 text,
321 color,
322 use_bg,
323 before,
324 );
325 }
326 PluginCommand::AddVirtualTextStyled {
327 buffer_id,
328 virtual_text_id,
329 position,
330 text,
331 fg,
332 bg,
333 bold,
334 italic,
335 before,
336 } => {
337 self.handle_add_virtual_text_styled(
338 buffer_id,
339 virtual_text_id,
340 position,
341 text,
342 fg,
343 bg,
344 bold,
345 italic,
346 before,
347 );
348 }
349 PluginCommand::RemoveVirtualText {
350 buffer_id,
351 virtual_text_id,
352 } => {
353 self.handle_remove_virtual_text(buffer_id, virtual_text_id);
354 }
355 PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
356 self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
357 }
358 PluginCommand::ClearVirtualTexts { buffer_id } => {
359 self.handle_clear_virtual_texts(buffer_id);
360 }
361 PluginCommand::AddVirtualLine {
362 buffer_id,
363 position,
364 text,
365 fg_color,
366 bg_color,
367 above,
368 namespace,
369 priority,
370 gutter_glyph,
371 gutter_color,
372 text_overlays,
373 } => {
374 self.handle_add_virtual_line(
375 buffer_id,
376 position,
377 text,
378 fg_color,
379 bg_color,
380 above,
381 namespace,
382 priority,
383 gutter_glyph,
384 gutter_color,
385 text_overlays,
386 );
387 }
388 PluginCommand::ClearVirtualTextNamespace {
389 buffer_id,
390 namespace,
391 } => {
392 self.handle_clear_virtual_text_namespace(buffer_id, namespace);
393 }
394
395 PluginCommand::AddConceal {
397 buffer_id,
398 namespace,
399 start,
400 end,
401 replacement,
402 } => {
403 self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
404 }
405 PluginCommand::ClearConcealNamespace {
406 buffer_id,
407 namespace,
408 } => {
409 self.handle_clear_conceal_namespace(buffer_id, namespace);
410 }
411 PluginCommand::ClearConcealsInRange {
412 buffer_id,
413 start,
414 end,
415 } => {
416 self.handle_clear_conceals_in_range(buffer_id, start, end);
417 }
418
419 PluginCommand::AddFold {
420 buffer_id,
421 start,
422 end,
423 placeholder,
424 } => {
425 self.handle_add_fold(buffer_id, start, end, placeholder);
426 }
427 PluginCommand::ClearFolds { buffer_id } => {
428 self.handle_clear_folds(buffer_id);
429 }
430 PluginCommand::SetFoldingRanges { buffer_id, ranges } => {
431 self.handle_set_folding_ranges(buffer_id, ranges);
432 }
433
434 PluginCommand::AddSoftBreak {
436 buffer_id,
437 namespace,
438 position,
439 indent,
440 } => {
441 self.handle_add_soft_break(buffer_id, namespace, position, indent);
442 }
443 PluginCommand::ClearSoftBreakNamespace {
444 buffer_id,
445 namespace,
446 } => {
447 self.handle_clear_soft_break_namespace(buffer_id, namespace);
448 }
449 PluginCommand::ClearSoftBreaksInRange {
450 buffer_id,
451 start,
452 end,
453 } => {
454 self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
455 }
456
457 PluginCommand::AddMenuItem {
459 menu_label,
460 item,
461 position,
462 } => {
463 self.handle_add_menu_item(menu_label, item, position);
464 }
465 PluginCommand::AddMenu { menu, position } => {
466 self.handle_add_menu(menu, position);
467 }
468 PluginCommand::RemoveMenuItem {
469 menu_label,
470 item_label,
471 } => {
472 self.handle_remove_menu_item(menu_label, item_label);
473 }
474 PluginCommand::RemoveMenu { menu_label } => {
475 self.handle_remove_menu(menu_label);
476 }
477
478 PluginCommand::FocusSplit { split_id } => {
480 self.handle_focus_split(split_id);
481 }
482 PluginCommand::SetSplitBuffer {
483 split_id,
484 buffer_id,
485 } => {
486 self.handle_set_split_buffer(split_id, buffer_id);
487 }
488 PluginCommand::SetSplitScroll { split_id, top_byte } => {
489 self.handle_set_split_scroll(split_id, top_byte);
490 }
491 PluginCommand::RequestHighlights {
492 buffer_id,
493 range,
494 request_id,
495 } => {
496 self.handle_request_highlights(buffer_id, range, request_id);
497 }
498 PluginCommand::CloseSplit { split_id } => {
499 self.handle_close_split(split_id);
500 }
501 PluginCommand::SetSplitRatio { split_id, ratio } => {
502 self.handle_set_split_ratio(split_id, ratio);
503 }
504 PluginCommand::SetSplitLabel { split_id, label } => {
505 self.handle_set_split_label(split_id, label);
506 }
507 PluginCommand::ClearSplitLabel { split_id } => {
508 self.handle_clear_split_label(split_id);
509 }
510 PluginCommand::GetSplitByLabel { label, request_id } => {
511 self.handle_get_split_by_label(label, request_id);
512 }
513 PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
514 self.handle_distribute_splits_evenly();
515 }
516 PluginCommand::SetBufferCursor {
517 buffer_id,
518 position,
519 } => {
520 self.handle_set_buffer_cursor(buffer_id, position);
521 }
522 PluginCommand::SetBufferShowCursors { buffer_id, show } => {
523 self.handle_set_buffer_show_cursors(buffer_id, show);
524 }
525
526 PluginCommand::SetLayoutHints {
528 buffer_id,
529 split_id,
530 range: _,
531 hints,
532 } => {
533 self.handle_set_layout_hints(buffer_id, split_id, hints);
534 }
535 PluginCommand::SetLineNumbers { buffer_id, enabled } => {
536 self.handle_set_line_numbers(buffer_id, enabled);
537 }
538 PluginCommand::SetViewMode { buffer_id, mode } => {
539 self.handle_set_view_mode(buffer_id, &mode);
540 }
541 PluginCommand::SetLineWrap {
542 buffer_id,
543 split_id,
544 enabled,
545 } => {
546 self.handle_set_line_wrap(buffer_id, split_id, enabled);
547 }
548 PluginCommand::SubmitViewTransform {
549 buffer_id,
550 split_id,
551 payload,
552 } => {
553 self.handle_submit_view_transform(buffer_id, split_id, payload);
554 }
555 PluginCommand::ClearViewTransform {
556 buffer_id: _,
557 split_id,
558 } => {
559 self.handle_clear_view_transform(split_id);
560 }
561 PluginCommand::SetViewState {
562 buffer_id,
563 key,
564 value,
565 } => {
566 self.handle_set_view_state(buffer_id, key, value);
567 }
568 PluginCommand::SetGlobalState {
569 plugin_name,
570 key,
571 value,
572 } => {
573 self.handle_set_global_state(plugin_name, key, value);
574 }
575 PluginCommand::SetWindowState {
576 plugin_name,
577 key,
578 value,
579 } => {
580 self.handle_set_session_state(plugin_name, key, value);
581 }
582 PluginCommand::RefreshLines { buffer_id } => {
583 self.handle_refresh_lines(buffer_id);
584 }
585 PluginCommand::RefreshAllLines => {
586 self.handle_refresh_all_lines();
587 }
588 PluginCommand::HookCompleted { .. } => {
589 }
591 PluginCommand::SetLineIndicator {
592 buffer_id,
593 line,
594 namespace,
595 symbol,
596 color,
597 priority,
598 } => {
599 self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
600 }
601 PluginCommand::SetLineIndicators {
602 buffer_id,
603 lines,
604 namespace,
605 symbol,
606 color,
607 priority,
608 } => {
609 self.handle_set_line_indicators(
610 buffer_id, lines, namespace, symbol, color, priority,
611 );
612 }
613 PluginCommand::ClearLineIndicators {
614 buffer_id,
615 namespace,
616 } => {
617 self.handle_clear_line_indicators(buffer_id, namespace);
618 }
619 PluginCommand::SetFileExplorerDecorations {
620 namespace,
621 decorations,
622 } => {
623 self.active_window_mut()
624 .handle_set_file_explorer_decorations(namespace, decorations);
625 }
626 PluginCommand::ClearFileExplorerDecorations { namespace } => {
627 self.active_window_mut()
628 .handle_clear_file_explorer_decorations(&namespace);
629 }
630 PluginCommand::SetFileExplorerSlots { namespace, slots } => {
631 self.active_window_mut()
632 .handle_set_file_explorer_slots(namespace, slots);
633 }
634 PluginCommand::ClearFileExplorerSlots { namespace } => {
635 self.active_window_mut()
636 .handle_clear_file_explorer_slots(&namespace);
637 }
638
639 PluginCommand::SetStatus { message } => {
641 self.handle_set_status(message);
642 }
643 PluginCommand::ApplyTheme { theme_name } => {
644 self.apply_theme(&theme_name);
645 }
646 PluginCommand::OverrideThemeColors { overrides } => {
647 self.handle_override_theme_colors(overrides);
648 }
649 PluginCommand::ReloadConfig => {
650 self.reload_config();
651 }
652 PluginCommand::SetSetting { path, value, .. } => {
653 self.handle_set_setting(path, value);
654 }
655 PluginCommand::AddPluginConfigField {
656 plugin_name,
657 field_name,
658 field_schema,
659 } => {
660 self.handle_add_plugin_config_field(plugin_name, field_name, field_schema);
661 }
662 PluginCommand::ReloadThemes { apply_theme } => {
663 self.handle_reload_themes(apply_theme);
664 }
665 PluginCommand::RegisterGrammar {
666 language,
667 grammar_path,
668 extensions,
669 } => {
670 self.handle_register_grammar(language, grammar_path, extensions);
671 }
672 PluginCommand::RegisterLanguageConfig { language, config } => {
673 self.handle_register_language_config(language, config);
674 }
675 PluginCommand::RegisterLspServer { language, config } => {
676 self.handle_register_lsp_server(language, config);
677 }
678 PluginCommand::ReloadGrammars { callback_id } => {
679 self.handle_reload_grammars(callback_id);
680 }
681 PluginCommand::CancelPrompt => {
682 self.cancel_prompt();
683 }
684 PluginCommand::StartPrompt {
685 label,
686 prompt_type,
687 floating_overlay,
688 } => {
689 self.handle_start_prompt(label, prompt_type, floating_overlay);
690 }
691 PluginCommand::StartPromptWithInitial {
692 label,
693 prompt_type,
694 initial_value,
695 floating_overlay,
696 } => {
697 self.handle_start_prompt_with_initial(
698 label,
699 prompt_type,
700 initial_value,
701 floating_overlay,
702 );
703 }
704 PluginCommand::StartPromptAsync {
705 label,
706 initial_value,
707 callback_id,
708 } => {
709 self.handle_start_prompt_async(label, initial_value, callback_id);
710 }
711 PluginCommand::AwaitNextKey { callback_id } => {
712 self.handle_await_next_key(callback_id);
713 }
714 PluginCommand::SetKeyCaptureActive { active } => {
715 self.handle_set_key_capture_active(active);
716 }
717 PluginCommand::SetPromptSuggestions {
718 suggestions,
719 selected_index,
720 } => {
721 self.handle_set_prompt_suggestions(suggestions, selected_index);
722 }
723 PluginCommand::SetPromptInputSync { sync } => {
724 self.handle_set_prompt_input_sync(sync);
725 }
726 PluginCommand::SetPromptTitle { title } => {
727 self.handle_set_prompt_title(title);
728 }
729 PluginCommand::SetPromptFooter { footer } => {
730 self.handle_set_prompt_footer(footer);
731 }
732 PluginCommand::SetPromptToolbar { spec } => {
733 self.handle_set_prompt_toolbar(spec);
734 }
735 PluginCommand::ToggleOverlayToolbarWidget { key } => {
736 self.toggle_overlay_toolbar_widget(&key);
737 }
738 PluginCommand::SetPromptStatus { status } => {
739 self.handle_set_prompt_status(status);
740 }
741 PluginCommand::SetPromptSelectedIndex { index } => {
742 self.handle_set_prompt_selected_index(index);
743 }
744
745 PluginCommand::CreateWindow { root, label } => {
748 self.handle_create_window(root, label);
749 }
750 PluginCommand::CreateWindowWithTerminal {
751 root,
752 label,
753 cwd,
754 command,
755 title,
756 resume,
757 request_id,
758 } => {
759 self.handle_create_window_with_terminal(
760 root, label, cwd, command, title, resume, request_id,
761 );
762 }
763 PluginCommand::SetActiveWindow { id } => {
764 self.set_active_window(id);
765 }
766 PluginCommand::SetActiveWindowAnimated { id, from_edge } => {
767 self.set_active_window_animated(id, &from_edge);
768 }
769 PluginCommand::SetWindowCycleOrder { ids } => {
770 self.window_cycle_order = if ids.is_empty() { None } else { Some(ids) };
771 }
772 PluginCommand::CloseWindow { id } => {
773 let _ = self.close_window(id);
774 }
775 PluginCommand::PrewarmWindow { id } => {
776 self.prewarm_window(id);
777 }
778
779 PluginCommand::WatchPath {
781 path,
782 recursive,
783 request_id,
784 } => {
785 self.handle_watch_path(path, recursive, request_id);
786 }
787 PluginCommand::UnwatchPath { handle } => {
788 self.file_watcher_manager.unwatch(handle);
789 }
790
791 PluginCommand::PreviewWindowInRect { id } => {
792 self.handle_preview_window_in_rect(id);
793 }
794
795 PluginCommand::RegisterCommand { command } => {
797 self.handle_register_command(command);
798 }
799 PluginCommand::RegisterStatusBarElement {
800 plugin_name,
801 token_name,
802 title,
803 } => {
804 self.handle_register_status_bar_element(plugin_name, token_name, title);
805 }
806 PluginCommand::SetStatusBarValue {
807 buffer_id,
808 key,
809 value,
810 } => {
811 self.handle_set_status_bar_value(buffer_id, key, value);
812 }
813 PluginCommand::UnregisterCommand { name } => {
814 self.handle_unregister_command(name);
815 }
816 PluginCommand::DefineMode {
817 name,
818 bindings,
819 read_only,
820 allow_text_input,
821 inherit_normal_bindings,
822 plugin_name,
823 } => {
824 self.handle_define_mode(
825 name,
826 bindings,
827 read_only,
828 allow_text_input,
829 inherit_normal_bindings,
830 plugin_name,
831 );
832 }
833
834 PluginCommand::OpenFileInBackground { path, window_id } => {
836 self.handle_open_file_in_background_routed(path, window_id);
837 }
838 PluginCommand::OpenFileAtLocation { path, line, column } => {
839 return self.handle_open_file_at_location(path, line, column);
840 }
841 PluginCommand::OpenFileInSplit {
842 split_id,
843 path,
844 line,
845 column,
846 } => {
847 return self.handle_open_file_in_split(split_id, path, line, column);
848 }
849 PluginCommand::ShowBuffer { buffer_id } => {
850 self.handle_show_buffer(buffer_id);
851 }
852 PluginCommand::CloseBuffer { buffer_id } => {
853 self.handle_close_buffer(buffer_id);
854 }
855 PluginCommand::CloseOtherBuffersInSplit {
856 buffer_id,
857 split_id,
858 } => {
859 self.handle_close_other_buffers_in_split(buffer_id, split_id);
860 }
861 PluginCommand::CloseAllBuffersInSplit { split_id } => {
862 self.handle_close_all_buffers_in_split(split_id);
863 }
864 PluginCommand::CloseBuffersToRightInSplit {
865 buffer_id,
866 split_id,
867 } => {
868 self.handle_close_buffers_to_right_in_split(buffer_id, split_id);
869 }
870 PluginCommand::CloseBuffersToLeftInSplit {
871 buffer_id,
872 split_id,
873 } => {
874 self.handle_close_buffers_to_left_in_split(buffer_id, split_id);
875 }
876
877 PluginCommand::MoveTabLeft => {
878 self.handle_move_tab_left();
879 }
880 PluginCommand::MoveTabRight => {
881 self.handle_move_tab_right();
882 }
883
884 PluginCommand::StartAnimationArea { id, rect, kind } => {
886 self.handle_start_animation_area(id, rect, kind);
887 }
888 PluginCommand::StartAnimationVirtualBuffer {
889 id,
890 buffer_id,
891 kind,
892 } => {
893 self.handle_start_animation_virtual_buffer(id, buffer_id, kind);
894 }
895 PluginCommand::CancelAnimation { id } => {
896 self.handle_cancel_animation(id);
897 }
898
899 PluginCommand::SendLspRequest {
901 language,
902 method,
903 params,
904 request_id,
905 } => {
906 self.handle_send_lsp_request(language, method, params, request_id);
907 }
908
909 PluginCommand::SetClipboard { text } => {
911 self.handle_set_clipboard(text);
912 }
913
914 PluginCommand::SpawnProcess {
916 command,
917 args,
918 cwd,
919 stdout_to,
920 callback_id,
921 } => {
922 self.handle_spawn_process(command, args, cwd, stdout_to, callback_id);
923 }
924
925 PluginCommand::SpawnHostProcess {
926 command,
927 args,
928 cwd,
929 callback_id,
930 } => {
931 self.handle_spawn_host_process(command, args, cwd, callback_id);
932 }
933
934 PluginCommand::KillHostProcess { process_id } => {
935 self.handle_kill_host_process(process_id);
936 }
937
938 PluginCommand::SetAuthority { payload } => {
939 self.handle_set_authority(payload);
940 }
941
942 PluginCommand::AttachRemoteAgent {
943 payload,
944 request_id,
945 } => {
946 self.handle_attach_remote_agent(payload, request_id);
947 }
948
949 PluginCommand::CancelRemoteAttach => {
950 self.cancel_remote_attaches();
951 }
952
953 PluginCommand::ClearAuthority => {
954 self.handle_clear_authority();
955 }
956
957 PluginCommand::SetEnv { snippet, dir } => {
958 self.handle_set_env(snippet, dir);
959 }
960
961 PluginCommand::ClearEnv => {
962 self.handle_clear_env();
963 }
964
965 PluginCommand::SetRemoteIndicatorState { state } => {
966 self.handle_set_remote_indicator_state(state);
967 }
968
969 PluginCommand::ClearRemoteIndicatorState => {
970 self.remote_indicator_override = None;
971 }
972
973 PluginCommand::SpawnProcessWait {
974 process_id,
975 callback_id,
976 } => {
977 self.handle_spawn_process_wait(process_id, callback_id);
978 }
979
980 PluginCommand::Delay {
981 callback_id,
982 duration_ms,
983 } => {
984 self.handle_delay(callback_id, duration_ms);
985 }
986
987 PluginCommand::HttpFetch {
988 url,
989 target_path,
990 callback_id,
991 } => {
992 self.handle_http_fetch(url, target_path, callback_id);
993 }
994
995 PluginCommand::SpawnBackgroundProcess {
996 process_id,
997 command,
998 args,
999 cwd,
1000 callback_id,
1001 } => {
1002 self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
1003 }
1004
1005 PluginCommand::KillBackgroundProcess { process_id } => {
1006 self.handle_kill_background_process(process_id);
1007 }
1008
1009 PluginCommand::CreateVirtualBuffer {
1011 name,
1012 mode,
1013 read_only,
1014 } => {
1015 self.handle_create_virtual_buffer(name, mode, read_only);
1016 }
1017 PluginCommand::CreateVirtualBufferWithContent {
1018 name,
1019 mode,
1020 read_only,
1021 entries,
1022 show_line_numbers,
1023 show_cursors,
1024 editing_disabled,
1025 hidden_from_tabs,
1026 request_id,
1027 } => {
1028 self.handle_create_virtual_buffer_with_content(
1029 name,
1030 mode,
1031 read_only,
1032 entries,
1033 show_line_numbers,
1034 show_cursors,
1035 editing_disabled,
1036 hidden_from_tabs,
1037 request_id,
1038 );
1039 }
1040 PluginCommand::CreateVirtualBufferInSplit {
1041 name,
1042 mode,
1043 read_only,
1044 entries,
1045 ratio,
1046 direction,
1047 panel_id,
1048 show_line_numbers,
1049 show_cursors,
1050 editing_disabled,
1051 line_wrap,
1052 before,
1053 role,
1054 request_id,
1055 } => {
1056 self.handle_create_virtual_buffer_in_split(
1057 name,
1058 mode,
1059 read_only,
1060 entries,
1061 ratio,
1062 direction,
1063 panel_id,
1064 show_line_numbers,
1065 show_cursors,
1066 editing_disabled,
1067 line_wrap,
1068 before,
1069 role,
1070 request_id,
1071 );
1072 }
1073 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1074 self.handle_set_virtual_buffer_content(buffer_id, entries);
1075 }
1076 PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1077 self.handle_get_text_properties_at_cursor(buffer_id);
1078 }
1079 PluginCommand::CreateVirtualBufferInExistingSplit {
1080 name,
1081 mode,
1082 read_only,
1083 entries,
1084 split_id,
1085 show_line_numbers,
1086 show_cursors,
1087 editing_disabled,
1088 line_wrap,
1089 request_id,
1090 } => {
1091 self.handle_create_virtual_buffer_in_existing_split(
1092 name,
1093 mode,
1094 read_only,
1095 entries,
1096 split_id,
1097 show_line_numbers,
1098 show_cursors,
1099 editing_disabled,
1100 line_wrap,
1101 request_id,
1102 );
1103 }
1104
1105 PluginCommand::SetContext { name, active } => {
1107 self.handle_set_context(name, active);
1108 }
1109
1110 PluginCommand::SetReviewDiffHunks { hunks } => {
1112 self.handle_set_review_diff_hunks(hunks);
1113 }
1114
1115 PluginCommand::ExecuteAction { action_name } => {
1117 self.handle_execute_action(action_name);
1118 }
1119 PluginCommand::ExecuteActions { actions } => {
1120 self.handle_execute_actions(actions);
1121 }
1122 PluginCommand::GetBufferText {
1123 buffer_id,
1124 start,
1125 end,
1126 request_id,
1127 } => {
1128 self.handle_get_buffer_text(buffer_id, start, end, request_id);
1129 }
1130 PluginCommand::GetLineStartPosition {
1131 buffer_id,
1132 line,
1133 request_id,
1134 } => {
1135 self.handle_get_line_start_position(buffer_id, line, request_id);
1136 }
1137 PluginCommand::GetLineEndPosition {
1138 buffer_id,
1139 line,
1140 request_id,
1141 } => {
1142 self.handle_get_line_end_position(buffer_id, line, request_id);
1143 }
1144 PluginCommand::GetBufferLineCount {
1145 buffer_id,
1146 request_id,
1147 } => {
1148 self.handle_get_buffer_line_count(buffer_id, request_id);
1149 }
1150 PluginCommand::GetCompositeCursorInfo { request_id } => {
1151 self.handle_get_composite_cursor_info(request_id);
1152 }
1153 PluginCommand::OpenFileStreaming { path, request_id } => {
1154 self.handle_open_file_streaming(path, request_id);
1155 }
1156 PluginCommand::RefreshBufferFromDisk {
1157 buffer_id,
1158 request_id,
1159 } => {
1160 self.handle_refresh_buffer_from_disk(buffer_id, request_id);
1161 }
1162 PluginCommand::SetBufferGroupPanelBuffer {
1163 group_id,
1164 panel_name,
1165 buffer_id,
1166 request_id,
1167 } => {
1168 self.handle_set_buffer_group_panel_buffer(
1169 group_id, panel_name, buffer_id, request_id,
1170 );
1171 }
1172 PluginCommand::ScrollToLineCenter {
1173 split_id,
1174 buffer_id,
1175 line,
1176 } => {
1177 self.handle_scroll_to_line_center(split_id, buffer_id, line);
1178 }
1179 PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1180 self.handle_scroll_buffer_to_line(buffer_id, line);
1181 }
1182 PluginCommand::SetEditorMode { mode } => {
1183 self.handle_set_editor_mode(mode);
1184 }
1185
1186 PluginCommand::ShowActionPopup {
1188 popup_id,
1189 title,
1190 message,
1191 actions,
1192 } => {
1193 self.handle_show_action_popup(popup_id, title, message, actions);
1194 }
1195
1196 PluginCommand::SetLspMenuContributions {
1197 plugin_id,
1198 language,
1199 items,
1200 } => {
1201 self.handle_set_lsp_menu_contributions(plugin_id, language, items);
1202 }
1203
1204 PluginCommand::DisableLspForLanguage { language } => {
1205 self.handle_disable_lsp_for_language(language);
1206 }
1207
1208 PluginCommand::RestartLspForLanguage { language } => {
1209 self.handle_restart_lsp_for_language(language);
1210 }
1211
1212 PluginCommand::SetLspRootUri { language, uri } => {
1213 self.handle_set_lsp_root_uri(language, uri);
1214 }
1215
1216 PluginCommand::CreateScrollSyncGroup {
1218 group_id,
1219 left_split,
1220 right_split,
1221 } => {
1222 self.handle_create_scroll_sync_group(group_id, left_split, right_split);
1223 }
1224 PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1225 self.handle_set_scroll_sync_anchors(group_id, anchors);
1226 }
1227 PluginCommand::RemoveScrollSyncGroup { group_id } => {
1228 self.handle_remove_scroll_sync_group(group_id);
1229 }
1230
1231 PluginCommand::CreateCompositeBuffer {
1233 name,
1234 mode,
1235 layout,
1236 sources,
1237 hunks,
1238 initial_focus_hunk,
1239 request_id,
1240 } => {
1241 self.handle_create_composite_buffer(
1242 name,
1243 mode,
1244 layout,
1245 sources,
1246 hunks,
1247 initial_focus_hunk,
1248 request_id,
1249 );
1250 }
1251 PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1252 self.handle_update_composite_alignment(buffer_id, hunks);
1253 }
1254 PluginCommand::CloseCompositeBuffer { buffer_id } => {
1255 self.active_window_mut().close_composite_buffer(buffer_id);
1256 }
1257 PluginCommand::FlushLayout => {
1258 self.flush_layout();
1259 }
1260 PluginCommand::CompositeNextHunk { buffer_id } => {
1261 self.handle_composite_next_hunk(buffer_id);
1262 }
1263 PluginCommand::CompositePrevHunk { buffer_id } => {
1264 self.handle_composite_prev_hunk(buffer_id);
1265 }
1266
1267 PluginCommand::CreateBufferGroup {
1269 name,
1270 mode,
1271 layout_json,
1272 request_id,
1273 } => {
1274 self.handle_create_buffer_group(name, mode, layout_json, request_id);
1275 }
1276 PluginCommand::SetPanelContent {
1277 group_id,
1278 panel_name,
1279 entries,
1280 } => {
1281 self.set_panel_content(group_id, panel_name, entries);
1282 }
1283 PluginCommand::CloseBufferGroup { group_id } => {
1284 self.close_buffer_group(group_id);
1285 }
1286 PluginCommand::FocusPanel {
1287 group_id,
1288 panel_name,
1289 } => {
1290 self.focus_panel(group_id, panel_name);
1291 }
1292
1293 PluginCommand::SaveBufferToPath { buffer_id, path } => {
1295 self.handle_save_buffer_to_path(buffer_id, path);
1296 }
1297
1298 #[cfg(feature = "plugins")]
1300 PluginCommand::LoadPlugin { path, callback_id } => {
1301 self.handle_load_plugin(path, callback_id);
1302 }
1303 #[cfg(feature = "plugins")]
1304 PluginCommand::UnloadPlugin { name, callback_id } => {
1305 self.handle_unload_plugin(name, callback_id);
1306 }
1307 #[cfg(feature = "plugins")]
1308 PluginCommand::ReloadPlugin { name, callback_id } => {
1309 self.handle_reload_plugin(name, callback_id);
1310 }
1311 #[cfg(feature = "plugins")]
1312 PluginCommand::ListPlugins { callback_id } => {
1313 self.handle_list_plugins(callback_id);
1314 }
1315 #[cfg(not(feature = "plugins"))]
1317 PluginCommand::LoadPlugin { .. }
1318 | PluginCommand::UnloadPlugin { .. }
1319 | PluginCommand::ReloadPlugin { .. }
1320 | PluginCommand::ListPlugins { .. } => {
1321 tracing::warn!("Plugin management commands require the 'plugins' feature");
1322 }
1323
1324 PluginCommand::CreateTerminal {
1326 cwd,
1327 direction,
1328 ratio,
1329 focus,
1330 persistent,
1331 window_id,
1332 command,
1333 title,
1334 request_id,
1335 } => {
1336 self.handle_create_terminal(
1337 cwd, direction, ratio, focus, persistent, window_id, command, title, request_id,
1338 );
1339 }
1340
1341 PluginCommand::SendTerminalInput { terminal_id, data } => {
1342 self.handle_send_terminal_input(terminal_id, data);
1343 }
1344
1345 PluginCommand::CloseTerminal { terminal_id } => {
1346 self.handle_close_terminal(terminal_id);
1347 }
1348
1349 PluginCommand::SignalWindow { id, signal } => {
1350 self.handle_signal_window(id, &signal);
1351 }
1352
1353 PluginCommand::GrepProject {
1354 pattern,
1355 fixed_string,
1356 case_sensitive,
1357 max_results,
1358 whole_words,
1359 callback_id,
1360 } => {
1361 self.handle_grep_project(
1362 pattern,
1363 fixed_string,
1364 case_sensitive,
1365 max_results,
1366 whole_words,
1367 callback_id,
1368 );
1369 }
1370
1371 PluginCommand::BeginSearch {
1372 pattern,
1373 fixed_string,
1374 case_sensitive,
1375 max_results,
1376 whole_words,
1377 source_buffer_id,
1378 handle_id,
1379 } => {
1380 self.handle_begin_search(
1381 pattern,
1382 fixed_string,
1383 case_sensitive,
1384 max_results,
1385 whole_words,
1386 source_buffer_id,
1387 handle_id,
1388 );
1389 }
1390
1391 PluginCommand::ReplaceInBuffer {
1392 file_path,
1393 buffer_id,
1394 matches,
1395 replacement,
1396 callback_id,
1397 } => {
1398 self.handle_replace_in_buffer(
1399 file_path,
1400 buffer_id,
1401 matches,
1402 replacement,
1403 callback_id,
1404 );
1405 }
1406
1407 PluginCommand::MountWidgetPanel {
1408 plugin,
1409 panel_id,
1410 buffer_id,
1411 spec,
1412 } => {
1413 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1414 self.handle_mount_widget_panel(key, buffer_id, spec);
1415 }
1416
1417 PluginCommand::UpdateWidgetPanel {
1418 plugin,
1419 panel_id,
1420 spec,
1421 } => {
1422 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1423 self.handle_update_widget_panel(&key, spec);
1424 }
1425
1426 PluginCommand::UnmountWidgetPanel { plugin, panel_id } => {
1427 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1428 self.handle_unmount_widget_panel(&key);
1429 }
1430
1431 PluginCommand::WidgetCommand {
1432 plugin,
1433 panel_id,
1434 action,
1435 } => {
1436 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1437 self.handle_widget_command(&key, action);
1438 }
1439
1440 PluginCommand::WidgetMutate {
1441 plugin,
1442 panel_id,
1443 mutation,
1444 } => {
1445 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1446 self.handle_widget_mutate(&key, mutation);
1447 }
1448
1449 PluginCommand::MountFloatingWidget {
1450 plugin,
1451 panel_id,
1452 spec,
1453 width_pct,
1454 height_pct,
1455 as_dock,
1456 focus_marker,
1457 } => {
1458 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1459 self.handle_mount_floating_widget(
1460 key,
1461 spec,
1462 width_pct,
1463 height_pct,
1464 as_dock,
1465 focus_marker,
1466 );
1467 }
1468
1469 PluginCommand::UpdateFloatingWidget {
1470 plugin,
1471 panel_id,
1472 spec,
1473 } => {
1474 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1475 self.handle_update_floating_widget(&key, spec);
1476 }
1477
1478 PluginCommand::UnmountFloatingWidget { plugin, panel_id } => {
1479 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1480 self.handle_unmount_floating_widget(&key);
1481 }
1482
1483 PluginCommand::FloatingPanelControl {
1484 plugin,
1485 panel_id,
1486 op,
1487 arg,
1488 } => {
1489 let key = crate::widgets::PanelKey::new(plugin, panel_id);
1490 self.handle_floating_panel_control(&key, &op, arg);
1491 }
1492 }
1493 Ok(())
1494 }
1495
1496 fn handle_watch_path(&mut self, path: std::path::PathBuf, recursive: bool, request_id: u64) {
1499 let result = if let Some(ref bridge) = self.async_bridge {
1500 self.file_watcher_manager.watch(bridge, &path, recursive)
1501 } else {
1502 Err(
1503 "watchPath: no async bridge — file watching is unavailable in this build"
1504 .to_string(),
1505 )
1506 };
1507 self.last_watch_response_for_test = Some((request_id, result.clone()));
1508 self.send_plugin_response(fresh_core::api::PluginResponse::WatchPathRegistered {
1509 request_id,
1510 result,
1511 });
1512 }
1513
1514 fn handle_set_env(&mut self, snippet: String, dir: Option<String>) {
1515 use crate::services::workspace_trust::TrustLevel;
1519 if self.authority().workspace_trust.level() == TrustLevel::Trusted {
1520 self.authority()
1521 .env_provider
1522 .set(snippet, dir.map(std::path::PathBuf::from));
1523 self.refresh_active_window_lsp_for_env();
1526 } else {
1527 self.active_window_mut().status_message =
1528 Some("Workspace not trusted — cannot activate environment".to_string());
1529 }
1530 }
1531
1532 fn handle_clear_env(&mut self) {
1533 let was_active = self.authority().env_provider.is_active();
1534 self.authority().env_provider.clear();
1535 if was_active {
1536 self.refresh_active_window_lsp_for_env();
1537 }
1538 }
1539
1540 fn refresh_active_window_lsp_for_env(&mut self) {
1557 let active_id = self.active_window;
1558 let running: Vec<String> = self
1559 .windows
1560 .get(&active_id)
1561 .map(|w| w.lsp.running_servers())
1562 .unwrap_or_default();
1563 if running.is_empty() {
1564 return;
1565 }
1566 let file_path = self
1567 .active_window()
1568 .buffer_metadata
1569 .get(&self.active_buffer())
1570 .and_then(|meta| meta.file_path().cloned());
1571 for language in &running {
1572 if let Some(w) = self.windows.get_mut(&active_id) {
1573 let _ = w.lsp.manual_restart(language, file_path.as_deref());
1574 }
1575 self.reopen_buffers_for_language(language);
1578 }
1579 }
1580
1581 fn handle_open_file_in_background_routed(
1582 &mut self,
1583 path: std::path::PathBuf,
1584 window_id: Option<fresh_core::WindowId>,
1585 ) {
1586 let route_to_inactive =
1587 window_id.filter(|&id| id != self.active_window && self.windows.contains_key(&id));
1588 if let Some(target) = route_to_inactive {
1589 self.handle_open_file_in_inactive_session(target, path);
1590 } else {
1591 self.handle_open_file_in_background(path);
1592 }
1593 }
1594
1595 fn handle_set_split_label(&mut self, split_id: SplitId, label: String) {
1598 self.windows
1599 .get_mut(&self.active_window)
1600 .and_then(|w| w.split_manager_mut())
1601 .expect("active window must have a populated split layout")
1602 .set_label(LeafId(split_id), label);
1603 }
1604
1605 fn handle_clear_split_label(&mut self, split_id: SplitId) {
1606 self.windows
1607 .get_mut(&self.active_window)
1608 .and_then(|w| w.split_manager_mut())
1609 .expect("active window must have a populated split layout")
1610 .clear_label(split_id);
1611 }
1612
1613 fn handle_reload_themes(&mut self, apply_theme: Option<String>) {
1614 self.reload_themes();
1615 if let Some(theme_name) = apply_theme {
1616 self.apply_theme(&theme_name);
1617 }
1618 }
1619
1620 fn handle_set_key_capture_active(&mut self, active: bool) {
1621 self.active_window_mut().key_capture_active = active;
1622 if !active {
1623 self.active_window_mut().pending_key_capture_buffer.clear();
1626 }
1627 }
1628
1629 fn handle_set_prompt_input_sync(&mut self, sync: bool) {
1630 if let Some(prompt) = &mut self.active_window_mut().prompt {
1631 prompt.sync_input_on_navigate = sync;
1632 }
1633 }
1634
1635 fn handle_set_prompt_title(&mut self, title: Vec<fresh_core::api::StyledText>) {
1636 if let Some(prompt) = &mut self.active_window_mut().prompt {
1637 prompt.title = title;
1638 }
1639 }
1640
1641 fn handle_set_prompt_footer(&mut self, footer: Vec<fresh_core::api::StyledText>) {
1642 if let Some(prompt) = &mut self.active_window_mut().prompt {
1643 prompt.footer = footer;
1644 }
1645 }
1646
1647 fn handle_set_prompt_toolbar(&mut self, spec: Option<fresh_core::api::WidgetSpec>) {
1648 if let Some(prompt) = &mut self.active_window_mut().prompt {
1649 prompt.toolbar_widget = spec;
1650 }
1651 }
1652
1653 fn handle_set_prompt_status(&mut self, status: String) {
1654 if let Some(prompt) = &mut self.active_window_mut().prompt {
1655 prompt.status = status;
1656 }
1657 }
1658
1659 fn handle_set_prompt_selected_index(&mut self, index: u32) {
1660 if let Some(prompt) = &mut self.active_window_mut().prompt {
1661 let len = prompt.suggestions.len();
1662 if len > 0 {
1663 prompt.selected_suggestion = Some((index as usize).min(len - 1));
1664 }
1665 }
1666 }
1667
1668 fn handle_create_window(&mut self, root: std::path::PathBuf, label: String) {
1669 if !root.is_absolute() {
1670 tracing::warn!(
1671 "CreateWindow rejected: root must be absolute, got {:?}",
1672 root
1673 );
1674 } else {
1675 let _ = self.create_window_at(root, label);
1676 }
1677 }
1678
1679 fn handle_preview_window_in_rect(&mut self, id: Option<fresh_core::WindowId>) {
1680 self.preview_window_id = match id {
1683 Some(sid) if sid != self.active_window && self.windows.contains_key(&sid) => Some(sid),
1684 _ => None,
1685 };
1686 }
1687
1688 fn handle_register_status_bar_element(
1689 &mut self,
1690 plugin_name: String,
1691 token_name: String,
1692 title: String,
1693 ) {
1694 if let Err(e) = self.register_status_bar_element(&plugin_name, &token_name, &title) {
1695 tracing::warn!("Failed to register statusbar element: {}", e);
1696 }
1697 }
1698
1699 fn handle_set_status_bar_value(&mut self, buffer_id: u64, key: String, value: String) {
1700 if let Err(e) =
1701 self.set_status_bar_value(fresh_core::BufferId(buffer_id as usize), &key, value)
1702 {
1703 tracing::debug!("Skipped statusbar value for stale buffer: {}", e);
1706 }
1707 }
1708
1709 fn handle_cancel_animation(&mut self, id: u64) {
1710 self.active_window_mut()
1711 .animations
1712 .cancel(crate::view::animation::AnimationId::from_raw(id));
1713 }
1714
1715 fn handle_clear_authority(&mut self) {
1716 tracing::info!("Plugin cleared authority; restoring local");
1717 self.clear_authority();
1718 }
1719
1720 fn handle_set_review_diff_hunks(&mut self, hunks: Vec<fresh_core::api::ReviewHunk>) {
1721 self.active_window_mut().review_hunks = hunks;
1722 tracing::debug!(
1723 "Set {} review hunks",
1724 self.active_window_mut().review_hunks.len()
1725 );
1726 }
1727
1728 fn handle_composite_next_hunk(&mut self, buffer_id: fresh_core::BufferId) {
1729 let split_id = self.active_window().effective_active_pair().0;
1732 self.active_window_mut()
1733 .composite_next_hunk(split_id, buffer_id);
1734 }
1735
1736 fn handle_composite_prev_hunk(&mut self, buffer_id: fresh_core::BufferId) {
1737 let split_id = self.active_window().effective_active_pair().0;
1738 self.active_window_mut()
1739 .composite_prev_hunk(split_id, buffer_id);
1740 }
1741
1742 fn configure_vbuf_display(
1748 &mut self,
1749 buffer_id: crate::model::event::BufferId,
1750 show_line_numbers: bool,
1751 show_cursors: bool,
1752 editing_disabled: bool,
1753 ) {
1754 if let Some(state) = self
1755 .windows
1756 .get_mut(&self.active_window)
1757 .map(|w| &mut w.buffers)
1758 .expect("active window present")
1759 .get_mut(&buffer_id)
1760 {
1761 state.margins.configure_for_line_numbers(show_line_numbers);
1762 state.show_cursors = show_cursors;
1763 state.editing_disabled = editing_disabled;
1764 }
1765 }
1766
1767 #[allow(clippy::too_many_arguments)]
1772 fn route_vbuf_to_existing_dock(
1773 &mut self,
1774 dock_leaf: crate::model::event::LeafId,
1775 name: String,
1776 mode: String,
1777 read_only: bool,
1778 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1779 panel_id: Option<&str>,
1780 show_line_numbers: bool,
1781 show_cursors: bool,
1782 editing_disabled: bool,
1783 request_id: Option<u64>,
1784 ) {
1785 let source_split_before_create = self.split_manager().active_split();
1788 let buffer_id =
1789 self.active_window_mut()
1790 .create_virtual_buffer(name.clone(), mode, read_only);
1791 self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
1792 if let Some(pid) = panel_id {
1793 self.panel_ids_mut().insert(pid.to_string(), buffer_id);
1794 }
1795 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
1796 tracing::error!("Failed to set virtual buffer content (dock route): {}", e);
1797 return;
1798 }
1799 self.split_manager_mut().set_active_split(dock_leaf);
1801 self.active_window_mut()
1802 .set_pane_buffer(dock_leaf, buffer_id);
1803 if dock_leaf != source_split_before_create {
1805 if let Some(source_view_state) = self
1806 .windows
1807 .get_mut(&self.active_window)
1808 .and_then(|w| w.split_view_states_mut())
1809 .expect("active window must have a populated split layout")
1810 .get_mut(&source_split_before_create)
1811 {
1812 source_view_state.remove_buffer(buffer_id);
1813 }
1814 }
1815 if let Some(req_id) = request_id {
1816 let result = fresh_core::api::VirtualBufferResult {
1817 buffer_id: buffer_id.0 as u64,
1818 split_id: Some(dock_leaf.0 .0 as u64),
1819 };
1820 self.plugin_manager.read().unwrap().resolve_callback(
1821 fresh_core::api::JsCallbackId::from(req_id),
1822 serde_json::to_string(&result).unwrap_or_default(),
1823 );
1824 }
1825 tracing::info!(
1826 "Routed virtual buffer '{}' into existing utility dock {:?}",
1827 name,
1828 dock_leaf
1829 );
1830 }
1831
1832 fn update_existing_vbuf_panel(
1835 &mut self,
1836 existing_buffer_id: crate::model::event::BufferId,
1837 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1838 request_id: Option<u64>,
1839 panel_name: &str,
1840 ) {
1841 match self.set_virtual_buffer_content(existing_buffer_id, entries) {
1842 Ok(()) => tracing::info!("Updated existing panel '{}' content", panel_name),
1843 Err(e) => tracing::error!("Failed to update panel content: {}", e),
1844 }
1845 let splits = self.split_manager().splits_for_buffer(existing_buffer_id);
1846 if let Some(&split_id) = splits.first() {
1847 self.split_manager_mut().set_active_split(split_id);
1848 self.active_window_mut()
1850 .set_pane_buffer(split_id, existing_buffer_id);
1851 tracing::debug!("Focused split {:?} containing panel buffer", split_id);
1852 }
1853 if let Some(req_id) = request_id {
1854 let result = fresh_core::api::VirtualBufferResult {
1855 buffer_id: existing_buffer_id.0 as u64,
1856 split_id: splits.first().map(|s| s.0 .0 as u64),
1857 };
1858 self.plugin_manager.read().unwrap().resolve_callback(
1859 fresh_core::api::JsCallbackId::from(req_id),
1860 serde_json::to_string(&result).unwrap_or_default(),
1861 );
1862 }
1863 }
1864
1865 fn handle_get_line_position(
1873 &mut self,
1874 buffer_id: crate::model::event::BufferId,
1875 line: u32,
1876 request_id: u64,
1877 want_end: bool,
1878 ) {
1879 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1880 let result = self
1881 .windows
1882 .get_mut(&self.active_window)
1883 .map(|w| &mut w.buffers)
1884 .expect("active window present")
1885 .get_mut(&actual_buffer_id)
1886 .and_then(|state| {
1887 let len = state.buffer.len();
1888 let content = state.get_text_range(0, len);
1889 buffer_line_byte_offset(&content, len, line as usize, want_end)
1890 });
1891 self.resolve_json_callback(request_id, result);
1892 }
1893
1894 fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1896 if let Some(state) = self
1897 .windows
1898 .get_mut(&self.active_window)
1899 .map(|w| &mut w.buffers)
1900 .expect("active window present")
1901 .get_mut(&buffer_id)
1902 {
1903 match state.buffer.save_to_file(&path) {
1905 Ok(()) => {
1906 if let Err(e) = self.finalize_save(Some(path)) {
1909 tracing::warn!("Failed to finalize save: {}", e);
1910 }
1911 tracing::debug!("Saved buffer {:?} to path", buffer_id);
1912 }
1913 Err(e) => {
1914 self.handle_set_status(format!("Error saving: {}", e));
1915 tracing::error!("Failed to save buffer to path: {}", e);
1916 }
1917 }
1918 } else {
1919 self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1920 tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1921 }
1922 }
1923
1924 #[cfg(feature = "plugins")]
1926 fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1927 let load_result = self.plugin_manager.read().unwrap().load_plugin(&path);
1928 match load_result {
1929 Ok(()) => {
1930 tracing::info!("Loaded plugin from {:?}", path);
1931 self.plugin_manager
1932 .read()
1933 .unwrap()
1934 .resolve_callback(callback_id, "true".to_string());
1935 }
1936 Err(e) => {
1937 tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1938 self.plugin_manager
1939 .read()
1940 .unwrap()
1941 .reject_callback(callback_id, format!("{}", e));
1942 }
1943 }
1944 }
1945
1946 #[cfg(feature = "plugins")]
1948 fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1949 let result = self.plugin_manager.write().unwrap().unload_plugin(&name);
1952 match result {
1953 Ok(()) => {
1954 tracing::info!("Unloaded plugin: {}", name);
1955 if let Ok(mut schemas) = self.plugin_schemas.write() {
1956 schemas.remove(&name);
1957 }
1958 self.plugin_manager
1959 .read()
1960 .unwrap()
1961 .resolve_callback(callback_id, "true".to_string());
1962 }
1963 Err(e) => {
1964 tracing::error!("Failed to unload plugin '{}': {}", name, e);
1965 self.plugin_manager
1966 .read()
1967 .unwrap()
1968 .reject_callback(callback_id, format!("{}", e));
1969 }
1970 }
1971 }
1972
1973 #[cfg(feature = "plugins")]
1975 fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1976 let path = self
1980 .plugin_manager
1981 .read()
1982 .unwrap()
1983 .list_plugins()
1984 .into_iter()
1985 .find(|p| p.name == name)
1986 .map(|p| p.path);
1987 let _ = path; let reload_result = self.plugin_manager.read().unwrap().reload_plugin(&name);
1989 match reload_result {
1990 Ok(()) => {
1991 tracing::info!("Reloaded plugin: {}", name);
1992 self.plugin_manager
1993 .read()
1994 .unwrap()
1995 .resolve_callback(callback_id, "true".to_string());
1996 }
1997 Err(e) => {
1998 tracing::error!("Failed to reload plugin '{}': {}", name, e);
1999 self.plugin_manager
2000 .read()
2001 .unwrap()
2002 .reject_callback(callback_id, format!("{}", e));
2003 }
2004 }
2005 }
2006
2007 #[cfg(feature = "plugins")]
2009 fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
2010 let plugins = self.plugin_manager.read().unwrap().list_plugins();
2011 let json_array: Vec<serde_json::Value> = plugins
2013 .iter()
2014 .map(|p| {
2015 serde_json::json!({
2016 "name": p.name,
2017 "path": p.path.to_string_lossy(),
2018 "enabled": p.enabled
2019 })
2020 })
2021 .collect();
2022 let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
2023 self.plugin_manager
2024 .read()
2025 .unwrap()
2026 .resolve_callback(callback_id, json_str);
2027 }
2028
2029 fn handle_execute_action(&mut self, action_name: String) {
2031 use crate::input::keybindings::Action;
2032 use std::collections::HashMap;
2033
2034 if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
2036 if let Err(e) = self.handle_action(action) {
2038 tracing::warn!("Failed to execute action '{}': {}", action_name, e);
2039 } else {
2040 tracing::debug!("Executed action: {}", action_name);
2041 }
2042 } else {
2043 tracing::warn!("Unknown action: {}", action_name);
2044 }
2045 }
2046
2047 fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
2050 use crate::input::keybindings::Action;
2051 use std::collections::HashMap;
2052
2053 const PLUGIN_FORBIDDEN_ACTIONS: &[&str] = &[
2060 "workspace_trust_trust",
2061 "workspace_trust_restrict",
2062 "workspace_trust_block",
2063 ];
2064
2065 for action_spec in actions {
2066 if PLUGIN_FORBIDDEN_ACTIONS.contains(&action_spec.action.as_str()) {
2067 tracing::warn!(
2068 "plugin attempted to set workspace trust via '{}' — denied; \
2069 plugins may request the prompt (workspace_trust_prompt), not set the level",
2070 action_spec.action
2071 );
2072 continue;
2073 }
2074 if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
2075 for _ in 0..action_spec.count {
2077 if let Err(e) = self.handle_action(action.clone()) {
2078 tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
2079 return; }
2081 }
2082 tracing::debug!(
2083 "Executed action '{}' {} time(s)",
2084 action_spec.action,
2085 action_spec.count
2086 );
2087 } else {
2088 tracing::warn!("Unknown action: {}", action_spec.action);
2089 return; }
2091 }
2092 }
2093
2094 fn handle_get_buffer_text(
2099 &mut self,
2100 buffer_id: BufferId,
2101 start: usize,
2102 end: usize,
2103 request_id: u64,
2104 ) {
2105 let result = if let Some(state) = self
2106 .windows
2107 .get_mut(&self.active_window)
2108 .map(|w| &mut w.buffers)
2109 .expect("active window present")
2110 .get_mut(&buffer_id)
2111 {
2112 let (start, end) = clamp_buffer_text_range(start, end, state.buffer.len());
2120 Ok(state.get_text_range(start, end))
2121 } else {
2122 Err(format!("Buffer {:?} not found", buffer_id))
2123 };
2124
2125 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2127 match result {
2128 Ok(text) => {
2129 let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
2131 self.plugin_manager
2132 .read()
2133 .unwrap()
2134 .resolve_callback(callback_id, json);
2135 }
2136 Err(error) => {
2137 self.plugin_manager
2138 .read()
2139 .unwrap()
2140 .reject_callback(callback_id, error);
2141 }
2142 }
2143 }
2144
2145 fn handle_set_editor_mode(&mut self, mode: Option<String>) {
2147 self.active_window_mut().editor_mode = mode.clone();
2148 tracing::debug!("Set editor mode: {:?}", mode);
2149 }
2150
2151 fn resolve_buffer_id(&self, buffer_id: BufferId) -> BufferId {
2153 if buffer_id.0 == 0 {
2154 self.active_buffer()
2155 } else {
2156 buffer_id
2157 }
2158 }
2159
2160 fn resolve_json_callback<T: serde::Serialize>(&mut self, request_id: u64, value: T) {
2162 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
2163 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
2164 self.plugin_manager
2165 .read()
2166 .unwrap()
2167 .resolve_callback(callback_id, json);
2168 }
2169
2170 fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2172 self.handle_get_line_position(buffer_id, line, request_id, false);
2173 }
2174
2175 fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
2178 self.handle_get_line_position(buffer_id, line, request_id, true);
2179 }
2180
2181 fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
2183 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2184
2185 let result = if let Some(state) = self
2186 .windows
2187 .get_mut(&self.active_window)
2188 .map(|w| &mut w.buffers)
2189 .expect("active window present")
2190 .get_mut(&actual_buffer_id)
2191 {
2192 let buffer_len = state.buffer.len();
2193 let content = state.get_text_range(0, buffer_len);
2194 let newlines = content.bytes().filter(|&b| b == b'\n').count();
2195 Some(if content.is_empty() {
2196 1
2197 } else {
2198 newlines + usize::from(!content.ends_with('\n'))
2199 })
2200 } else {
2201 None
2202 };
2203
2204 self.resolve_json_callback(request_id, result);
2205 }
2206
2207 fn handle_get_composite_cursor_info(&mut self, request_id: u64) {
2213 let info = self.active_window().active_composite_cursor_info();
2214 let value = info.map(|(focused_pane, pane_count, lines)| {
2215 serde_json::json!({
2216 "focusedPane": focused_pane,
2217 "paneCount": pane_count,
2218 "lines": lines,
2219 })
2220 });
2221 self.resolve_json_callback(request_id, value);
2222 }
2223
2224 fn handle_open_file_streaming(&mut self, path: std::path::PathBuf, request_id: u64) {
2241 if !self.authority().filesystem.exists(&path) {
2244 if let Some(parent) = path.parent() {
2245 if !parent.as_os_str().is_empty() {
2246 if let Err(e) = std::fs::create_dir_all(parent) {
2247 tracing::warn!(
2248 "openFileStreaming: failed to create parent dir {:?}: {}",
2249 parent,
2250 e
2251 );
2252 self.resolve_json_callback::<Option<u64>>(request_id, None);
2253 return;
2254 }
2255 }
2256 }
2257 if let Err(e) = std::fs::write(&path, b"") {
2258 tracing::warn!(
2259 "openFileStreaming: failed to create empty file at {:?}: {}",
2260 path,
2261 e
2262 );
2263 self.resolve_json_callback::<Option<u64>>(request_id, None);
2264 return;
2265 }
2266 }
2267
2268 let buffer_id = match self.open_file_no_focus(&path) {
2272 Ok(id) => id,
2273 Err(e) => {
2274 tracing::warn!(
2275 "openFileStreaming: open_file_no_focus failed for {:?}: {}",
2276 path,
2277 e
2278 );
2279 self.resolve_json_callback::<Option<u64>>(request_id, None);
2280 return;
2281 }
2282 };
2283
2284 if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2289 meta.hidden_from_tabs = true;
2290 meta.auto_revert_enabled = false;
2291 }
2292 let active_split = self
2293 .windows
2294 .get(&self.active_window)
2295 .and_then(|w| w.buffers.splits())
2296 .map(|(mgr, _)| mgr)
2297 .expect("active window must have a populated split layout")
2298 .active_split();
2299 if let Some(vs) = self
2300 .windows
2301 .get_mut(&self.active_window)
2302 .and_then(|w| w.split_view_states_mut())
2303 .expect("active window must have a populated split layout")
2304 .get_mut(&active_split)
2305 {
2306 use crate::view::split::TabTarget;
2307 vs.open_buffers
2308 .retain(|t| !matches!(t, TabTarget::Buffer(b) if *b == buffer_id));
2309 }
2310
2311 self.resolve_json_callback(request_id, Some(buffer_id.0));
2312 }
2313
2314 fn handle_set_buffer_group_panel_buffer(
2317 &mut self,
2318 group_id: usize,
2319 panel_name: String,
2320 buffer_id: BufferId,
2321 request_id: u64,
2322 ) {
2323 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2324 let ok = self.set_buffer_group_panel_buffer(group_id, panel_name, actual_buffer_id);
2325 self.resolve_json_callback(request_id, ok);
2326 }
2327
2328 fn handle_refresh_buffer_from_disk(&mut self, buffer_id: BufferId, request_id: u64) {
2332 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2333
2334 let path = self
2335 .windows
2336 .get(&self.active_window)
2337 .and_then(|w| w.buffers.splits())
2338 .map(|(_, _)| ())
2339 .and_then(|_| {
2340 self.windows
2341 .get(&self.active_window)?
2342 .buffers
2343 .get(&actual_buffer_id)?
2344 .buffer
2345 .file_path()
2346 .map(|p| p.to_path_buf())
2347 });
2348
2349 let Some(path) = path else {
2350 self.resolve_json_callback::<Option<usize>>(request_id, None);
2352 return;
2353 };
2354
2355 let new_size = match self.authority().filesystem.metadata(&path) {
2356 Ok(m) => m.size as usize,
2357 Err(_) => {
2358 self.resolve_json_callback::<Option<usize>>(request_id, None);
2359 return;
2360 }
2361 };
2362
2363 let new_total = if let Some(state) = self
2364 .windows
2365 .get_mut(&self.active_window)
2366 .map(|w| &mut w.buffers)
2367 .expect("active window present")
2368 .get_mut(&actual_buffer_id)
2369 {
2370 let old = state.buffer.total_bytes();
2371 if new_size > old {
2372 state.buffer.extend_streaming(&path, new_size);
2373 }
2374 state.buffer.total_bytes()
2375 } else {
2376 self.resolve_json_callback::<Option<usize>>(request_id, None);
2377 return;
2378 };
2379
2380 self.resolve_json_callback(request_id, Some(new_total));
2381 }
2382
2383 fn handle_scroll_to_line_center(
2385 &mut self,
2386 split_id: SplitId,
2387 buffer_id: BufferId,
2388 line: usize,
2389 ) {
2390 let actual_split_id = if split_id.0 == 0 {
2391 self.windows
2392 .get(&self.active_window)
2393 .and_then(|w| w.buffers.splits())
2394 .map(|(mgr, _)| mgr)
2395 .expect("active window must have a populated split layout")
2396 .active_split()
2397 } else {
2398 LeafId(split_id)
2399 };
2400 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
2401
2402 let viewport_height = if let Some(view_state) = self
2404 .windows
2405 .get(&self.active_window)
2406 .and_then(|w| w.buffers.splits())
2407 .map(|(_, vs)| vs)
2408 .expect("active window must have a populated split layout")
2409 .get(&actual_split_id)
2410 {
2411 view_state.viewport.height as usize
2412 } else {
2413 return;
2414 };
2415
2416 let lines_above = viewport_height / 2;
2418 let target_line = line.saturating_sub(lines_above);
2419
2420 self.active_window_mut().scroll_split_viewport_to(
2421 actual_buffer_id,
2422 actual_split_id,
2423 target_line,
2424 true,
2425 );
2426 }
2427
2428 fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
2438 if !self
2439 .windows
2440 .get(&self.active_window)
2441 .map(|w| &w.buffers)
2442 .expect("active window present")
2443 .contains_key(&buffer_id)
2444 {
2445 return;
2446 }
2447
2448 let mut target_leaves: Vec<LeafId> = Vec::new();
2450
2451 for leaf_id in self
2453 .windows
2454 .get(&self.active_window)
2455 .and_then(|w| w.buffers.splits())
2456 .map(|(mgr, _)| mgr)
2457 .expect("active window must have a populated split layout")
2458 .root()
2459 .leaf_split_ids()
2460 {
2461 if let Some(vs) = self
2462 .windows
2463 .get(&self.active_window)
2464 .and_then(|w| w.buffers.splits())
2465 .map(|(_, vs)| vs)
2466 .expect("active window must have a populated split layout")
2467 .get(&leaf_id)
2468 {
2469 if vs.active_buffer == buffer_id {
2470 target_leaves.push(leaf_id);
2471 }
2472 }
2473 }
2474
2475 for (_group_leaf_id, node) in self.active_window().grouped_subtrees.iter() {
2477 if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
2478 for inner_leaf in layout.leaf_split_ids() {
2479 if let Some(vs) = self
2480 .windows
2481 .get(&self.active_window)
2482 .and_then(|w| w.buffers.splits())
2483 .map(|(_, vs)| vs)
2484 .expect("active window must have a populated split layout")
2485 .get(&inner_leaf)
2486 {
2487 if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
2488 target_leaves.push(inner_leaf);
2489 }
2490 }
2491 }
2492 }
2493 }
2494
2495 if target_leaves.is_empty() {
2496 return;
2497 }
2498
2499 self.active_window_mut()
2500 .scroll_buffer_to_line_in_splits(buffer_id, &target_leaves, line);
2501 }
2502
2503 fn handle_spawn_host_process(
2504 &mut self,
2505 command: String,
2506 args: Vec<String>,
2507 cwd: Option<String>,
2508 callback_id: JsCallbackId,
2509 ) {
2510 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2525 use tokio::io::{AsyncReadExt, BufReader};
2526 use tokio::process::Command as TokioCommand;
2527
2528 let effective_cwd = cwd.or_else(|| {
2529 std::env::current_dir()
2530 .map(|p| p.to_string_lossy().to_string())
2531 .ok()
2532 });
2533 let sender = bridge.sender();
2534 let process_id = callback_id.as_u64();
2535
2536 if let crate::services::workspace_trust::SpawnDecision::Deny(reason) = self
2543 .authority()
2544 .workspace_trust
2545 .decide(&command, effective_cwd.as_deref())
2546 {
2547 #[allow(clippy::let_underscore_must_use)]
2548 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2549 process_id,
2550 stdout: String::new(),
2551 stderr: reason,
2552 exit_code: -1,
2553 });
2554 return;
2555 }
2556
2557 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
2558 self.host_process_handles.insert(process_id, kill_tx);
2559
2560 runtime.spawn(async move {
2561 use crate::services::process_hidden::HideWindow;
2562 let mut cmd = TokioCommand::new(&command);
2563 cmd.args(&args);
2564 cmd.stdout(std::process::Stdio::piped());
2565 cmd.stderr(std::process::Stdio::piped());
2566 cmd.hide_window();
2567 if let Some(ref dir) = effective_cwd {
2568 cmd.current_dir(dir);
2569 }
2570 let mut child = match cmd.spawn() {
2571 Ok(c) => c,
2572 Err(e) => {
2573 #[allow(clippy::let_underscore_must_use)]
2574 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2575 process_id,
2576 stdout: String::new(),
2577 stderr: e.to_string(),
2578 exit_code: -1,
2579 });
2580 return;
2581 }
2582 };
2583
2584 let stdout_pipe = child.stdout.take();
2590 let stderr_pipe = child.stderr.take();
2591
2592 let stdout_fut = async {
2593 let mut buf = String::new();
2594 if let Some(s) = stdout_pipe {
2595 #[allow(clippy::let_underscore_must_use)]
2596 let _ = BufReader::new(s).read_to_string(&mut buf).await;
2597 }
2598 buf
2599 };
2600 let stderr_fut = async {
2601 let mut buf = String::new();
2602 if let Some(s) = stderr_pipe {
2603 #[allow(clippy::let_underscore_must_use)]
2604 let _ = BufReader::new(s).read_to_string(&mut buf).await;
2605 }
2606 buf
2607 };
2608 let wait_fut = async {
2609 tokio::select! {
2610 status = child.wait() => {
2611 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
2612 }
2613 _ = &mut kill_rx => {
2614 #[allow(clippy::let_underscore_must_use)]
2618 let _ = child.start_kill();
2619 child
2620 .wait()
2621 .await
2622 .map(|s| s.code().unwrap_or(-1))
2623 .unwrap_or(-1)
2624 }
2625 }
2626 };
2627 let (stdout, stderr, exit_code) = tokio::join!(stdout_fut, stderr_fut, wait_fut);
2628
2629 #[allow(clippy::let_underscore_must_use)]
2630 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2631 process_id,
2632 stdout,
2633 stderr,
2634 exit_code,
2635 });
2636 });
2637 } else {
2638 self.plugin_manager
2639 .read()
2640 .unwrap()
2641 .reject_callback(callback_id, "Async runtime not available".to_string());
2642 }
2643 }
2644
2645 fn handle_spawn_background_process(
2646 &mut self,
2647 process_id: u64,
2648 command: String,
2649 args: Vec<String>,
2650 cwd: Option<String>,
2651 callback_id: JsCallbackId,
2652 ) {
2653 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2655 use tokio::io::{AsyncBufReadExt, BufReader};
2656 use tokio::process::Command as TokioCommand;
2657
2658 let effective_cwd = cwd.unwrap_or_else(|| {
2659 std::env::current_dir()
2660 .map(|p| p.to_string_lossy().to_string())
2661 .unwrap_or_else(|_| ".".to_string())
2662 });
2663
2664 let sender = bridge.sender();
2665 let sender_stdout = sender.clone();
2666 let sender_stderr = sender.clone();
2667 let callback_id_u64 = callback_id.as_u64();
2668
2669 #[allow(clippy::let_underscore_must_use)]
2671 let handle = runtime.spawn(async move {
2672 use crate::services::process_hidden::HideWindow;
2673 let mut child = match TokioCommand::new(&command)
2674 .args(&args)
2675 .current_dir(&effective_cwd)
2676 .stdout(std::process::Stdio::piped())
2677 .stderr(std::process::Stdio::piped())
2678 .hide_window()
2679 .spawn()
2680 {
2681 Ok(child) => child,
2682 Err(e) => {
2683 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2684 fresh_core::api::PluginAsyncMessage::ProcessExit {
2685 process_id,
2686 callback_id: callback_id_u64,
2687 exit_code: -1,
2688 },
2689 ));
2690 tracing::error!("Failed to spawn background process: {}", e);
2691 return;
2692 }
2693 };
2694
2695 let stdout = child.stdout.take();
2697 let stderr = child.stderr.take();
2698 let pid = process_id;
2699
2700 if let Some(stdout) = stdout {
2702 let sender = sender_stdout;
2703 tokio::spawn(async move {
2704 let reader = BufReader::new(stdout);
2705 let mut lines = reader.lines();
2706 while let Ok(Some(line)) = lines.next_line().await {
2707 let _ =
2708 sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2709 fresh_core::api::PluginAsyncMessage::ProcessStdout {
2710 process_id: pid,
2711 data: line + "\n",
2712 },
2713 ));
2714 }
2715 });
2716 }
2717
2718 if let Some(stderr) = stderr {
2720 let sender = sender_stderr;
2721 tokio::spawn(async move {
2722 let reader = BufReader::new(stderr);
2723 let mut lines = reader.lines();
2724 while let Ok(Some(line)) = lines.next_line().await {
2725 let _ =
2726 sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2727 fresh_core::api::PluginAsyncMessage::ProcessStderr {
2728 process_id: pid,
2729 data: line + "\n",
2730 },
2731 ));
2732 }
2733 });
2734 }
2735
2736 let exit_code = match child.wait().await {
2738 Ok(status) => status.code().unwrap_or(-1),
2739 Err(_) => -1,
2740 };
2741
2742 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2743 fresh_core::api::PluginAsyncMessage::ProcessExit {
2744 process_id,
2745 callback_id: callback_id_u64,
2746 exit_code,
2747 },
2748 ));
2749 });
2750
2751 self.background_process_handles
2753 .insert(process_id, handle.abort_handle());
2754 } else {
2755 self.plugin_manager
2757 .read()
2758 .unwrap()
2759 .reject_callback(callback_id, "Async runtime not available".to_string());
2760 }
2761 }
2762
2763 #[allow(clippy::too_many_arguments)]
2764 fn handle_create_virtual_buffer_with_content(
2765 &mut self,
2766 name: String,
2767 mode: String,
2768 read_only: bool,
2769 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2770 show_line_numbers: bool,
2771 show_cursors: bool,
2772 editing_disabled: bool,
2773 hidden_from_tabs: bool,
2774 request_id: Option<u64>,
2775 ) {
2776 let buffer_id = if hidden_from_tabs {
2781 self.active_window_mut().create_virtual_buffer_detached(
2782 name.clone(),
2783 mode.clone(),
2784 read_only,
2785 )
2786 } else {
2787 self.active_window_mut()
2788 .create_virtual_buffer(name.clone(), mode.clone(), read_only)
2789 };
2790 tracing::info!(
2791 "Created virtual buffer '{}' with mode '{}' (id={:?}, detached={})",
2792 name,
2793 mode,
2794 buffer_id,
2795 hidden_from_tabs
2796 );
2797
2798 self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
2804 if !hidden_from_tabs {
2805 let active_split = self.split_manager().active_split();
2806 if let Some(view_state) = self
2807 .windows
2808 .get_mut(&self.active_window)
2809 .and_then(|w| w.split_view_states_mut())
2810 .expect("active window must have a populated split layout")
2811 .get_mut(&active_split)
2812 {
2813 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2814 }
2815 } else if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2816 meta.hidden_from_tabs = true;
2817 }
2818
2819 match self.set_virtual_buffer_content(buffer_id, entries) {
2821 Ok(()) => {
2822 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
2823 if !hidden_from_tabs {
2826 self.set_active_buffer(buffer_id);
2827 tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
2828 }
2829
2830 if let Some(req_id) = request_id {
2832 tracing::info!(
2833 "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
2834 req_id,
2835 buffer_id
2836 );
2837 let result = fresh_core::api::VirtualBufferResult {
2839 buffer_id: buffer_id.0 as u64,
2840 split_id: None,
2841 };
2842 self.plugin_manager.read().unwrap().resolve_callback(
2843 fresh_core::api::JsCallbackId::from(req_id),
2844 serde_json::to_string(&result).unwrap_or_default(),
2845 );
2846 tracing::info!(
2847 "CreateVirtualBufferWithContent: resolve_callback sent for request_id={}",
2848 req_id
2849 );
2850 }
2851 }
2852 Err(e) => {
2853 tracing::error!("Failed to set virtual buffer content: {}", e);
2854 }
2855 }
2856 }
2857
2858 #[allow(clippy::too_many_arguments)]
2859 fn handle_create_virtual_buffer_in_split(
2860 &mut self,
2861 name: String,
2862 mode: String,
2863 read_only: bool,
2864 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2865 ratio: f32,
2866 direction: Option<String>,
2867 panel_id: Option<String>,
2868 show_line_numbers: bool,
2869 show_cursors: bool,
2870 editing_disabled: bool,
2871 line_wrap: Option<bool>,
2872 before: bool,
2873 role: Option<String>,
2874 request_id: Option<u64>,
2875 ) {
2876 let split_role: Option<crate::view::split::SplitRole> = match role.as_deref() {
2879 Some("utility_dock") => Some(crate::view::split::SplitRole::UtilityDock),
2880 _ => None,
2881 };
2882
2883 if let Some(dock_leaf) = split_role.and_then(|r| self.split_manager().find_leaf_by_role(r))
2887 {
2888 return self.route_vbuf_to_existing_dock(
2889 dock_leaf,
2890 name,
2891 mode,
2892 read_only,
2893 entries,
2894 panel_id.as_deref(),
2895 show_line_numbers,
2896 show_cursors,
2897 editing_disabled,
2898 request_id,
2899 );
2900 }
2903
2904 if let Some(pid) = panel_id.as_deref() {
2907 let maybe_existing = self.panel_ids().get(pid).copied();
2908 if let Some(existing_id) = maybe_existing {
2909 let buffer_alive = self
2910 .windows
2911 .get(&self.active_window)
2912 .map(|w| w.buffers.contains_key(&existing_id))
2913 .unwrap_or(false);
2914 if buffer_alive {
2915 return self.update_existing_vbuf_panel(existing_id, entries, request_id, pid);
2916 }
2917 tracing::warn!(
2919 "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
2920 pid,
2921 existing_id
2922 );
2923 self.panel_ids_mut().remove(pid);
2924 }
2925 }
2926
2927 let source_split_before_create = self.split_manager().active_split();
2934
2935 let buffer_id =
2936 self.active_window_mut()
2937 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2938 tracing::info!(
2939 "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
2940 name,
2941 mode,
2942 buffer_id
2943 );
2944
2945 self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
2946
2947 if let Some(pid) = panel_id {
2948 self.panel_ids_mut().insert(pid, buffer_id);
2949 }
2950
2951 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2952 tracing::error!("Failed to set virtual buffer content: {}", e);
2953 return;
2954 }
2955
2956 let split_dir = match direction.as_deref() {
2957 Some("vertical") => crate::model::event::SplitDirection::Vertical,
2958 _ => crate::model::event::SplitDirection::Horizontal,
2959 };
2960
2961 let split_result = if split_role == Some(crate::view::split::SplitRole::UtilityDock) {
2966 self.split_manager_mut()
2967 .split_root_positioned(split_dir, buffer_id, ratio, before)
2968 } else {
2969 self.split_manager_mut()
2970 .split_active_positioned(split_dir, buffer_id, ratio, before)
2971 };
2972
2973 let created_split_id = match split_result {
2974 Ok(new_split_id) => {
2975 if new_split_id != source_split_before_create {
2979 if let Some(src_vs) = self
2980 .windows
2981 .get_mut(&self.active_window)
2982 .and_then(|w| w.split_view_states_mut())
2983 .expect("active window must have a populated split layout")
2984 .get_mut(&source_split_before_create)
2985 {
2986 src_vs.remove_buffer(buffer_id);
2987 }
2988 }
2989
2990 let mut view_state = SplitViewState::with_buffer(
2991 self.terminal_width,
2992 self.terminal_height,
2993 buffer_id,
2994 );
2995 view_state.apply_config_defaults(
2996 self.config.editor.line_numbers,
2997 self.config.editor.highlight_current_line,
2998 line_wrap.unwrap_or_else(|| {
2999 self.active_window().resolve_line_wrap_for_buffer(buffer_id)
3000 }),
3001 self.config.editor.wrap_indent,
3002 self.active_window()
3003 .resolve_wrap_column_for_buffer(buffer_id),
3004 self.config.editor.rulers.clone(),
3005 self.config.editor.scroll_offset,
3006 );
3007 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
3008 self.windows
3009 .get_mut(&self.active_window)
3010 .and_then(|w| w.split_view_states_mut())
3011 .expect("active window must have a populated split layout")
3012 .insert(new_split_id, view_state);
3013
3014 self.split_manager_mut().set_active_split(new_split_id);
3015
3016 if let Some(target_role) = split_role {
3020 self.split_manager_mut().clear_role(target_role);
3021 self.split_manager_mut()
3022 .set_leaf_role(new_split_id, Some(target_role));
3023 tracing::info!(
3024 "Tagged new dock leaf {:?} with role {:?}",
3025 new_split_id,
3026 target_role
3027 );
3028 }
3029
3030 tracing::info!(
3031 "Created {:?} split with virtual buffer {:?}",
3032 split_dir,
3033 buffer_id
3034 );
3035 Some(new_split_id)
3036 }
3037 Err(e) => {
3038 tracing::error!("Failed to create split: {}", e);
3039 self.set_active_buffer(buffer_id);
3040 None
3041 }
3042 };
3043
3044 if let Some(req_id) = request_id {
3045 tracing::trace!(
3046 "CreateVirtualBufferInSplit: resolving callback for request_id={}, \
3047 buffer_id={:?}, split_id={:?}",
3048 req_id,
3049 buffer_id,
3050 created_split_id
3051 );
3052 let result = fresh_core::api::VirtualBufferResult {
3053 buffer_id: buffer_id.0 as u64,
3054 split_id: created_split_id.map(|s| s.0 .0 as u64),
3055 };
3056 self.plugin_manager.read().unwrap().resolve_callback(
3057 fresh_core::api::JsCallbackId::from(req_id),
3058 serde_json::to_string(&result).unwrap_or_default(),
3059 );
3060 }
3061 }
3062
3063 #[allow(clippy::too_many_arguments)]
3064 fn handle_create_virtual_buffer_in_existing_split(
3065 &mut self,
3066 name: String,
3067 mode: String,
3068 read_only: bool,
3069 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3070 split_id: SplitId,
3071 show_line_numbers: bool,
3072 show_cursors: bool,
3073 editing_disabled: bool,
3074 line_wrap: Option<bool>,
3075 request_id: Option<u64>,
3076 ) {
3077 let buffer_id =
3079 self.active_window_mut()
3080 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3081 tracing::info!(
3082 "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
3083 name,
3084 mode,
3085 split_id,
3086 buffer_id
3087 );
3088
3089 self.configure_vbuf_display(buffer_id, show_line_numbers, show_cursors, editing_disabled);
3090
3091 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
3092 tracing::error!("Failed to set virtual buffer content: {}", e);
3093 return;
3094 }
3095
3096 let leaf_id = LeafId(split_id);
3099 self.windows
3100 .get_mut(&self.active_window)
3101 .and_then(|w| w.split_manager_mut())
3102 .expect("active window must have a populated split layout")
3103 .set_active_split(leaf_id);
3104 self.active_window_mut().set_pane_buffer(leaf_id, buffer_id);
3105
3106 if let Some(view_state) = self
3112 .windows
3113 .get_mut(&self.active_window)
3114 .and_then(|w| w.split_view_states_mut())
3115 .expect("active window must have a populated split layout")
3116 .get_mut(&leaf_id)
3117 {
3118 view_state.switch_buffer(buffer_id);
3119 view_state.add_buffer(buffer_id);
3120 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
3121
3122 if let Some(wrap) = line_wrap {
3124 view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
3125 }
3126 }
3127
3128 tracing::info!(
3129 "Displayed virtual buffer {:?} in split {:?}",
3130 buffer_id,
3131 split_id
3132 );
3133
3134 if let Some(req_id) = request_id {
3136 let result = fresh_core::api::VirtualBufferResult {
3137 buffer_id: buffer_id.0 as u64,
3138 split_id: Some(split_id.0 as u64),
3139 };
3140 self.plugin_manager.read().unwrap().resolve_callback(
3141 fresh_core::api::JsCallbackId::from(req_id),
3142 serde_json::to_string(&result).unwrap_or_default(),
3143 );
3144 }
3145 }
3146
3147 fn handle_show_action_popup(
3148 &mut self,
3149 popup_id: String,
3150 title: String,
3151 message: String,
3152 actions: Vec<fresh_core::api::ActionPopupAction>,
3153 ) {
3154 tracing::info!(
3155 "Action popup requested: id={}, title={}, actions={}",
3156 popup_id,
3157 title,
3158 actions.len()
3159 );
3160
3161 let items: Vec<crate::model::event::PopupListItemData> = actions
3163 .iter()
3164 .map(|action| crate::model::event::PopupListItemData {
3165 text: action.label.clone(),
3166 detail: None,
3167 icon: None,
3168 data: Some(action.id.clone()),
3169 })
3170 .collect();
3171
3172 drop(actions);
3177
3178 let popup_data = crate::model::event::PopupData {
3180 kind: crate::model::event::PopupKindHint::List,
3181 title: Some(title),
3182 description: Some(message),
3183 transient: false,
3184 content: crate::model::event::PopupContentData::List { items, selected: 0 },
3185 position: crate::model::event::PopupPositionData::BottomRight,
3186 width: 60,
3187 max_height: 15,
3188 bordered: true,
3189 };
3190
3191 let (popup_bg, popup_border_fg) = {
3201 let theme = self.theme();
3202 (theme.popup_bg, theme.popup_border_fg)
3203 };
3204 let mut popup_obj =
3205 crate::state::convert_popup_data_to_popup(&popup_data, popup_bg, popup_border_fg);
3206 popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
3207 popup_id: popup_id.clone(),
3208 };
3209
3210 while self
3222 .active_state()
3223 .popups
3224 .top()
3225 .is_some_and(|p| matches!(p.resolver, crate::view::popup::PopupResolver::LspStatus))
3226 {
3227 self.active_state_mut().popups.hide();
3228 }
3229
3230 let existing_idx = self.global_popups.all().iter().position(|p| {
3237 matches!(
3238 &p.resolver,
3239 crate::view::popup::PopupResolver::PluginAction { popup_id: id } if id == &popup_id,
3240 )
3241 });
3242 if let Some(idx) = existing_idx {
3243 if let Some(slot) = self.global_popups.get_mut(idx) {
3244 *slot = popup_obj;
3245 }
3246 } else {
3247 self.global_popups.show(popup_obj);
3248 }
3249 tracing::info!(
3250 "Action popup shown: id={}, stack_depth={}",
3251 popup_id,
3252 self.global_popups.all().len()
3253 );
3254 }
3255
3256 fn handle_set_lsp_menu_contributions(
3266 &mut self,
3267 plugin_id: String,
3268 language: String,
3269 items: Vec<fresh_core::api::LspMenuItem>,
3270 ) {
3271 let key = (language.clone(), plugin_id.clone());
3272 if items.is_empty() {
3273 self.active_window_mut().lsp_menu_contributions.remove(&key);
3274 } else {
3275 self.active_window_mut()
3276 .lsp_menu_contributions
3277 .insert(key, items);
3278 }
3279 self.refresh_lsp_status_popup_if_open();
3284 }
3285
3286 #[allow(clippy::too_many_arguments)]
3287 fn handle_create_window_with_terminal(
3288 &mut self,
3289 root: std::path::PathBuf,
3290 label: String,
3291 cwd: Option<String>,
3292 command: Option<Vec<String>>,
3293 title: Option<String>,
3294 resume: Option<Vec<String>>,
3295 request_id: u64,
3296 ) {
3297 let callback_id = JsCallbackId::from(request_id);
3298 if !root.is_absolute() {
3299 let msg = format!(
3300 "createWindowWithTerminal: root must be absolute, got {:?}",
3301 root
3302 );
3303 tracing::warn!("{}", msg);
3304 self.plugin_manager
3305 .read()
3306 .unwrap()
3307 .reject_callback(callback_id, msg);
3308 return;
3309 }
3310 let cwd_buf = cwd.map(std::path::PathBuf::from);
3311 let new_authority = self.local_session_authority(&root);
3319 match self.create_window_with_terminal(
3320 root,
3321 label,
3322 cwd_buf,
3323 command,
3324 title,
3325 new_authority,
3326 resume,
3327 ) {
3328 Ok((window_id, terminal_id, buffer_id)) => {
3329 let api_result = fresh_core::api::SessionWithTerminalResult {
3330 window_id: window_id.0,
3331 terminal_id: terminal_id.0 as u64,
3332 buffer_id: buffer_id.0 as u64,
3333 };
3334 self.plugin_manager.read().unwrap().resolve_callback(
3335 callback_id,
3336 serde_json::to_string(&api_result).unwrap_or_default(),
3337 );
3338 }
3339 Err(e) => {
3340 tracing::error!("createWindowWithTerminal failed: {e}");
3341 self.plugin_manager
3342 .read()
3343 .unwrap()
3344 .reject_callback(callback_id, format!("createWindowWithTerminal: {e}"));
3345 }
3346 }
3347 }
3348
3349 #[allow(clippy::too_many_arguments)]
3350 fn handle_create_terminal(
3351 &mut self,
3352 cwd: Option<String>,
3353 direction: Option<String>,
3354 ratio: Option<f32>,
3355 focus: Option<bool>,
3356 persistent: bool,
3357 target_session_id: Option<fresh_core::WindowId>,
3358 command: Option<Vec<String>>,
3359 title: Option<String>,
3360 request_id: u64,
3361 ) {
3362 let target_id = target_session_id
3369 .filter(|id| self.windows.contains_key(id))
3370 .unwrap_or(self.active_window);
3371 let is_active_target = target_id == self.active_window;
3372
3373 let cwd_buf = cwd.map(std::path::PathBuf::from);
3374 let split_direction = direction.as_deref().map(|d| match d {
3375 "horizontal" => crate::model::event::SplitDirection::Horizontal,
3376 _ => crate::model::event::SplitDirection::Vertical,
3377 });
3378
3379 let prev_active = if is_active_target {
3387 Some(self.active_window().active_buffer())
3388 } else {
3389 None
3390 };
3391
3392 let result = {
3393 let target = self
3394 .windows
3395 .get_mut(&target_id)
3396 .expect("target window present (existence checked above)");
3397 target.create_plugin_terminal(
3398 cwd_buf,
3399 split_direction,
3400 ratio,
3401 focus.unwrap_or(true),
3402 persistent,
3403 command,
3404 title.filter(|t| !t.is_empty()),
3405 )
3406 };
3407 match result {
3408 Ok((terminal_id, buffer_id, created_split_id)) => {
3409 if is_active_target {
3410 let new_active = self.active_window().active_buffer();
3411 if prev_active != Some(new_active) {
3412 #[cfg(feature = "plugins")]
3413 self.update_plugin_state_snapshot();
3414 #[cfg(feature = "plugins")]
3415 self.plugin_manager.read().unwrap().run_hook(
3416 "buffer_activated",
3417 crate::services::plugins::hooks::HookArgs::BufferActivated {
3418 buffer_id: new_active,
3419 },
3420 );
3421 }
3422 }
3423 let api_result = fresh_core::api::TerminalResult {
3424 buffer_id: buffer_id.0 as u64,
3425 terminal_id: terminal_id.0 as u64,
3426 split_id: created_split_id.map(|s| s.0 .0 as u64),
3427 };
3428 self.plugin_manager.read().unwrap().resolve_callback(
3429 fresh_core::api::JsCallbackId::from(request_id),
3430 serde_json::to_string(&api_result).unwrap_or_default(),
3431 );
3432 tracing::info!(
3433 "Plugin created terminal {:?} with buffer {:?} in window {:?}",
3434 terminal_id,
3435 buffer_id,
3436 target_id
3437 );
3438 }
3439 Err(e) => {
3440 tracing::error!("Failed to create terminal for plugin: {e}");
3441 self.plugin_manager.read().unwrap().reject_callback(
3442 fresh_core::api::JsCallbackId::from(request_id),
3443 format!("Failed to create terminal: {e}"),
3444 );
3445 }
3446 }
3447 }
3448
3449 fn handle_get_split_by_label(&mut self, label: String, request_id: u64) {
3452 let split_id = self
3453 .windows
3454 .get(&self.active_window)
3455 .and_then(|w| w.buffers.splits())
3456 .map(|(mgr, _)| mgr)
3457 .expect("active window must have a populated split layout")
3458 .find_split_by_label(&label);
3459 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
3460 let json =
3461 serde_json::to_string(&split_id.map(|s| s.0 .0)).unwrap_or_else(|_| "null".to_string());
3462 self.plugin_manager
3463 .read()
3464 .unwrap()
3465 .resolve_callback(callback_id, json);
3466 }
3467
3468 fn handle_set_buffer_show_cursors(&mut self, buffer_id: BufferId, show: bool) {
3469 if let Some(state) = self
3470 .windows
3471 .get_mut(&self.active_window)
3472 .map(|w| &mut w.buffers)
3473 .expect("active window present")
3474 .get_mut(&buffer_id)
3475 {
3476 state.show_cursors = show;
3477 state.cursor_visibility_locked = true;
3480 } else {
3481 tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
3482 }
3483 }
3484
3485 fn handle_override_theme_colors(
3486 &mut self,
3487 overrides: std::collections::HashMap<String, [u8; 3]>,
3488 ) {
3489 let pairs = overrides
3490 .into_iter()
3491 .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
3492 let applied = self.theme.write().unwrap().override_colors(pairs);
3493 if applied > 0 {
3494 self.reapply_all_overlays();
3497 }
3498 }
3499
3500 fn handle_await_next_key(&mut self, callback_id: fresh_core::api::JsCallbackId) {
3501 if let Some(payload) = self
3505 .active_window_mut()
3506 .pending_key_capture_buffer
3507 .pop_front()
3508 {
3509 let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
3510 self.plugin_manager
3511 .read()
3512 .unwrap()
3513 .resolve_callback(callback_id, json);
3514 } else {
3515 self.active_window_mut()
3516 .pending_next_key_callbacks
3517 .push_back(callback_id);
3518 }
3519 }
3520
3521 fn handle_spawn_process(
3522 &mut self,
3523 command: String,
3524 args: Vec<String>,
3525 cwd: Option<String>,
3526 stdout_to: Option<std::path::PathBuf>,
3527 callback_id: fresh_core::api::JsCallbackId,
3528 ) {
3529 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3530 let effective_cwd = cwd.or_else(|| {
3531 std::env::current_dir()
3532 .map(|p| p.to_string_lossy().to_string())
3533 .ok()
3534 });
3535 let sender = bridge.sender();
3536 let spawner = self.authority().process_spawner.clone();
3537
3538 let process_id = callback_id.as_u64();
3543 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
3544 self.host_process_handles.insert(process_id, kill_tx);
3545
3546 runtime.spawn(async move {
3547 #[allow(clippy::let_underscore_must_use)]
3548 let outcome = spawner
3549 .spawn_cancellable(command, args, effective_cwd, stdout_to, kill_rx)
3550 .await;
3551 match outcome {
3552 Ok(result) => {
3553 #[allow(clippy::let_underscore_must_use)]
3554 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3555 process_id,
3556 stdout: result.stdout,
3557 stderr: result.stderr,
3558 exit_code: result.exit_code,
3559 });
3560 }
3561 Err(e) => {
3562 #[allow(clippy::let_underscore_must_use)]
3563 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3564 process_id,
3565 stdout: String::new(),
3566 stderr: e.to_string(),
3567 exit_code: -1,
3568 });
3569 }
3570 }
3571 });
3572 } else {
3573 self.plugin_manager
3574 .read()
3575 .unwrap()
3576 .reject_callback(callback_id, "Async runtime not available".to_string());
3577 }
3578 }
3579
3580 fn handle_kill_host_process(&mut self, process_id: u64) {
3581 if let Some(tx) = self.host_process_handles.remove(&process_id) {
3585 #[allow(clippy::let_underscore_must_use)]
3586 let _ = tx.send(());
3587 tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
3588 } else {
3589 tracing::debug!(
3590 "KillHostProcess: unknown process_id={} (already exited?)",
3591 process_id
3592 );
3593 }
3594 }
3595
3596 fn handle_set_authority(&mut self, payload: serde_json::Value) {
3597 match serde_json::from_value::<crate::services::authority::AuthorityPayload>(payload) {
3600 Ok(parsed) => {
3601 let trust = std::sync::Arc::clone(&self.authority().workspace_trust);
3604 let env = std::sync::Arc::clone(&self.authority().env_provider);
3605 let spec = crate::services::authority::SessionAuthoritySpec::Plugin(parsed.clone());
3611 match crate::services::authority::Authority::from_plugin_payload(parsed, trust, env)
3612 {
3613 Ok(auth) => {
3614 tracing::info!("Plugin installed new authority");
3615 self.active_window_mut().authority_spec = spec;
3616 self.install_authority(auth);
3617 }
3618 Err(e) => {
3619 tracing::warn!("setAuthority: invalid payload: {}", e);
3620 self.set_status_message(format!("setAuthority rejected: {}", e));
3621 }
3622 }
3623 }
3624 Err(e) => {
3625 tracing::warn!("setAuthority: failed to parse payload: {}", e);
3626 self.set_status_message(format!("setAuthority rejected: {}", e));
3627 }
3628 }
3629 }
3630
3631 pub(crate) fn reconnect_dormant_session_if_needed(&mut self, window_id: fresh_core::WindowId) {
3639 if self.session_keepalives.contains_key(&window_id) {
3642 return;
3643 }
3644 let Some(spec) = self
3645 .windows
3646 .get(&window_id)
3647 .map(|w| w.authority_spec.clone())
3648 else {
3649 return;
3650 };
3651 match spec {
3652 crate::services::authority::SessionAuthoritySpec::Local => {}
3653 crate::services::authority::SessionAuthoritySpec::RemoteAgent(agent_spec) => {
3654 let request_id = u64::MAX - window_id.0 as u64;
3658 if self.remote_attach_inflight.contains(&request_id) {
3659 return;
3660 }
3661 self.start_remote_connect(agent_spec, Some(window_id), request_id);
3662 }
3663 crate::services::authority::SessionAuthoritySpec::Plugin(_) => {
3664 tracing::debug!(
3668 "dormant container session {window_id}: reattach is plugin-driven (TODO)"
3669 );
3670 }
3671 }
3672 }
3673
3674 fn handle_attach_remote_agent(&mut self, payload: serde_json::Value, request_id: u64) {
3675 let spec =
3678 match serde_json::from_value::<crate::services::authority::RemoteAgentSpec>(payload) {
3679 Ok(spec) => spec,
3680 Err(e) => {
3681 tracing::warn!("attachRemoteAgent: invalid payload: {}", e);
3682 self.reject_remote_attach(request_id, format!("invalid attach spec: {e}"));
3683 return;
3684 }
3685 };
3686 self.start_remote_connect(spec, None, request_id);
3689 }
3690
3691 pub(crate) fn start_remote_connect(
3698 &mut self,
3699 spec: crate::services::authority::RemoteAgentSpec,
3700 reconnect_window: Option<fresh_core::WindowId>,
3701 request_id: u64,
3702 ) {
3703 let runtime = self.tokio_runtime.clone();
3706 let sender = self.async_bridge.as_ref().map(|b| b.sender());
3707 let (Some(runtime), Some(sender)) = (runtime, sender) else {
3708 self.reject_remote_attach(request_id, "async runtime not available".to_string());
3709 return;
3710 };
3711
3712 self.remote_attach_inflight.insert(request_id);
3717 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
3718 self.remote_attach_cancels.insert(request_id, cancel_tx);
3719
3720 let window_mode = spec.window;
3724 let window_label = spec.label.clone();
3725 let window_command = spec.command.clone();
3726 let trust = std::sync::Arc::new(crate::services::workspace_trust::WorkspaceTrust::new(
3733 None,
3734 self.authority().workspace_trust.level(),
3735 ));
3736 let env = std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive());
3737
3738 use crate::services::authority::RemoteTransportSpec;
3745 let base_env = spec.base_env.clone();
3746 let session_spec =
3749 crate::services::authority::SessionAuthoritySpec::RemoteAgent(spec.clone());
3750 let mode_for = |label: &str| {
3751 if let Some(window_id) = reconnect_window {
3752 crate::services::async_bridge::RemoteAttachMode::Reconnect { window_id }
3753 } else if window_mode {
3754 crate::services::async_bridge::RemoteAttachMode::Window {
3755 label: window_label.clone().unwrap_or_else(|| label.to_string()),
3756 command: window_command.clone(),
3757 }
3758 } else {
3759 crate::services::async_bridge::RemoteAttachMode::Restart
3760 }
3761 };
3762
3763 match spec.transport {
3764 RemoteTransportSpec::KubectlExec { .. } => {
3765 let (target, base_env) = spec.into_kube_target();
3766 let label = target.display();
3767 let workspace = target.workspace.clone().map(std::path::PathBuf::from);
3769 let mode = mode_for(&label);
3770 self.set_status_message(format!("Connecting to {label}…"));
3771 runtime.spawn(async move {
3772 let outcome = crate::services::authority::connect_kube_authority(
3773 target,
3774 base_env,
3775 trust,
3776 env,
3777 Some(cancel_rx),
3778 )
3779 .await;
3780 let msg = match outcome {
3781 Ok((authority, keepalive)) => AsyncMessage::RemoteAttachReady(
3782 crate::services::async_bridge::RemoteAttachReady {
3783 authority,
3784 keepalive: Box::new(keepalive),
3785 working_dir: workspace,
3786 mode,
3787 spec: session_spec,
3788 request_id,
3789 },
3790 ),
3791 Err(e) => AsyncMessage::RemoteAttachFailed {
3792 error: e.to_string(),
3793 request_id,
3794 },
3795 };
3796 #[allow(clippy::let_underscore_must_use)]
3797 let _ = sender.send(msg);
3798 });
3799 }
3800 RemoteTransportSpec::Ssh {
3801 user,
3802 host,
3803 port,
3804 identity_file,
3805 remote_path,
3806 extra_args,
3807 } => {
3808 let _ = base_env; let params = crate::services::remote::ConnectionParams {
3810 user: user.clone().filter(|u| !u.is_empty()),
3811 host: host.clone(),
3812 port,
3813 identity_file: identity_file.map(std::path::PathBuf::from),
3814 extra_args,
3815 };
3816 let target = params.ssh_target();
3818 let label = match port {
3819 Some(p) => format!("ssh:{target}:{p}"),
3820 None => format!("ssh:{target}"),
3821 };
3822 let workspace = remote_path.clone().map(std::path::PathBuf::from);
3823 let mode = mode_for(&label);
3824 self.set_status_message(format!("Connecting to {label}…"));
3825 runtime.spawn(async move {
3826 let outcome = crate::services::authority::connect_ssh_authority(
3827 params,
3828 remote_path,
3829 trust,
3830 env,
3831 Some(cancel_rx),
3832 )
3833 .await;
3834 let msg = match outcome {
3835 Ok((authority, keepalive)) => AsyncMessage::RemoteAttachReady(
3836 crate::services::async_bridge::RemoteAttachReady {
3837 authority,
3838 keepalive: Box::new(keepalive),
3839 working_dir: workspace,
3840 mode,
3841 spec: session_spec,
3842 request_id,
3843 },
3844 ),
3845 Err(e) => AsyncMessage::RemoteAttachFailed {
3846 error: e.to_string(),
3847 request_id,
3848 },
3849 };
3850 #[allow(clippy::let_underscore_must_use)]
3851 let _ = sender.send(msg);
3852 });
3853 }
3854 }
3855 }
3856
3857 fn handle_set_remote_indicator_state(&mut self, state: serde_json::Value) {
3858 match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(state)
3861 {
3862 Ok(over) => {
3863 self.remote_indicator_override = Some(over);
3864 }
3865 Err(e) => {
3866 tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
3867 self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
3868 }
3869 }
3870 }
3871
3872 fn handle_spawn_process_wait(
3873 &mut self,
3874 process_id: u64,
3875 callback_id: fresh_core::api::JsCallbackId,
3876 ) {
3877 tracing::warn!(
3878 "SpawnProcessWait not fully implemented - process_id={}",
3879 process_id
3880 );
3881 self.plugin_manager.read().unwrap().reject_callback(
3882 callback_id,
3883 format!(
3884 "SpawnProcessWait not yet fully implemented for process_id={}",
3885 process_id
3886 ),
3887 );
3888 }
3889
3890 fn handle_delay(&mut self, callback_id: fresh_core::api::JsCallbackId, duration_ms: u64) {
3891 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3892 let sender = bridge.sender();
3893 let callback_id_u64 = callback_id.as_u64();
3894 runtime.spawn(async move {
3895 tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
3896 #[allow(clippy::let_underscore_must_use)]
3897 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
3898 fresh_core::api::PluginAsyncMessage::DelayComplete {
3899 callback_id: callback_id_u64,
3900 },
3901 ));
3902 });
3903 } else {
3904 std::thread::sleep(std::time::Duration::from_millis(duration_ms));
3905 self.plugin_manager
3906 .read()
3907 .unwrap()
3908 .resolve_callback(callback_id, "null".to_string());
3909 }
3910 }
3911
3912 fn handle_http_fetch(
3913 &mut self,
3914 url: String,
3915 target_path: std::path::PathBuf,
3916 callback_id: fresh_core::api::JsCallbackId,
3917 ) {
3918 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3919 let sender = bridge.sender();
3920 let process_id = callback_id.as_u64();
3921
3922 runtime.spawn(async move {
3923 let fetch = tokio::task::spawn_blocking(move || {
3924 crate::services::http::download_to_file(&url, &target_path)
3925 })
3926 .await;
3927
3928 let (stdout, stderr, exit_code) = match fetch {
3929 Ok(Ok(status)) => {
3930 if (200..300).contains(&status) {
3931 (String::new(), String::new(), 0)
3932 } else {
3933 (String::new(), format!("HTTP {}", status), i32::from(status))
3934 }
3935 }
3936 Ok(Err(e)) => (String::new(), e, -1),
3937 Err(e) => (String::new(), format!("fetch task failed: {}", e), -1),
3938 };
3939
3940 #[allow(clippy::let_underscore_must_use)]
3941 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3942 process_id,
3943 stdout,
3944 stderr,
3945 exit_code,
3946 });
3947 });
3948 } else {
3949 self.plugin_manager
3950 .read()
3951 .unwrap()
3952 .reject_callback(callback_id, "Async runtime not available".to_string());
3953 }
3954 }
3955
3956 fn handle_kill_background_process(&mut self, process_id: u64) {
3957 if let Some(handle) = self.background_process_handles.remove(&process_id) {
3958 handle.abort();
3959 tracing::debug!("Killed background process {}", process_id);
3960 }
3961 }
3962
3963 fn handle_create_virtual_buffer(&mut self, name: String, mode: String, read_only: bool) {
3964 let buffer_id =
3965 self.active_window_mut()
3966 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3967 tracing::info!(
3968 "Created virtual buffer '{}' with mode '{}' (id={:?})",
3969 name,
3970 mode,
3971 buffer_id
3972 );
3973 }
3975
3976 fn handle_set_virtual_buffer_content(
3977 &mut self,
3978 buffer_id: BufferId,
3979 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3980 ) {
3981 match self.set_virtual_buffer_content(buffer_id, entries) {
3982 Ok(()) => {
3983 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
3984 }
3985 Err(e) => {
3986 tracing::error!("Failed to set virtual buffer content: {}", e);
3987 }
3988 }
3989 }
3990
3991 fn handle_mount_widget_panel(
3992 &mut self,
3993 panel_key: crate::widgets::PanelKey,
3994 buffer_id: BufferId,
3995 spec: fresh_core::api::WidgetSpec,
3996 ) {
3997 let prev = std::collections::HashMap::new();
4002 let prev_focus = String::new();
4003 let panel_width = self.widget_panel_width(buffer_id);
4004 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
4005 let focus_cursor = out.focus_cursor;
4006 self.widget_registry.mount(
4007 panel_key.clone(),
4008 buffer_id,
4009 spec,
4010 out.hits,
4011 out.instance_states,
4012 out.focus_key,
4013 out.tabbable,
4014 );
4015 let entries = out.entries;
4016 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
4017 tracing::error!(
4018 "Failed to render mounted widget panel {} into {:?}: {}",
4019 panel_key,
4020 buffer_id,
4021 e
4022 );
4023 } else {
4024 tracing::debug!(
4025 "Mounted widget panel {} into buffer {:?}",
4026 panel_key,
4027 buffer_id
4028 );
4029 }
4030 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
4031 }
4032
4033 fn handle_update_widget_panel(
4034 &mut self,
4035 panel_key: &crate::widgets::PanelKey,
4036 spec: fresh_core::api::WidgetSpec,
4037 ) {
4038 let prev = match self.widget_registry.instance_states(panel_key) {
4039 Some(s) => s.clone(),
4040 None => {
4041 tracing::debug!(
4042 "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
4043 panel_key
4044 );
4045 return;
4046 }
4047 };
4048 let prev_focus = self
4049 .widget_registry
4050 .focus_key(panel_key)
4051 .map(|s| s.to_string())
4052 .unwrap_or_default();
4053 let buffer_id_for_width = self
4054 .widget_registry
4055 .buffer_and_spec(panel_key)
4056 .map(|(b, _)| b)
4057 .unwrap_or(BufferId(0));
4058 let panel_width = self.widget_panel_width(buffer_id_for_width);
4059 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
4060 let focus_cursor = out.focus_cursor;
4061 let entries = out.entries;
4062 match self.widget_registry.update(
4063 panel_key,
4064 spec,
4065 out.hits,
4066 out.instance_states,
4067 out.focus_key,
4068 out.tabbable,
4069 ) {
4070 Ok(buffer_id) => {
4071 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
4072 tracing::error!("Failed to render updated widget panel {}: {}", panel_key, e);
4073 }
4074 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
4075 }
4076 Err(()) => {
4077 tracing::debug!(
4078 "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
4079 panel_key
4080 );
4081 }
4082 }
4083 }
4084
4085 fn handle_widget_mutate(
4091 &mut self,
4092 panel_key: &crate::widgets::PanelKey,
4093 mutation: fresh_core::api::WidgetMutation,
4094 ) {
4095 use fresh_core::api::WidgetMutation;
4096
4097 if self.widget_registry.get(panel_key).is_none() {
4099 tracing::debug!(
4100 "WidgetMutate for unknown panel {} ignored (not mounted)",
4101 panel_key
4102 );
4103 return;
4104 }
4105
4106 match mutation {
4107 WidgetMutation::SetValue {
4108 widget_key,
4109 value,
4110 cursor_byte,
4111 } => {
4112 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4119 let (scroll, multiline, completions, sel_idx, scroll_off, navigated) =
4127 match panel.instance_states.get(&widget_key) {
4128 Some(crate::widgets::WidgetInstanceState::Text {
4129 editor,
4130 scroll,
4131 completions,
4132 completion_selected_index,
4133 completion_scroll_offset,
4134 completion_navigated,
4135 }) => (
4136 *scroll,
4137 editor.multiline,
4138 completions.clone(),
4139 *completion_selected_index,
4140 *completion_scroll_offset,
4141 *completion_navigated,
4142 ),
4143 _ => (0u32, true, Vec::new(), 0usize, 0u32, false),
4144 };
4145 let mut editor = if multiline {
4146 crate::primitives::text_edit::TextEdit::with_text(&value)
4147 } else {
4148 crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
4149 };
4150 let target = match cursor_byte {
4151 Some(c) if c >= 0 => (c as usize).min(value.len()),
4152 _ => value.len(),
4153 };
4154 editor.set_cursor_from_flat(target);
4155 panel.instance_states.insert(
4156 widget_key,
4157 crate::widgets::WidgetInstanceState::Text {
4158 editor,
4159 scroll,
4160 completions,
4161 completion_selected_index: sel_idx,
4162 completion_scroll_offset: scroll_off,
4163 completion_navigated: navigated,
4164 },
4165 );
4166 }
4167 }
4168 WidgetMutation::SetChecked {
4169 widget_key,
4170 checked,
4171 } => {
4172 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4176 crate::widgets::set_toggle_checked_in_spec(
4177 &mut panel.spec,
4178 &widget_key,
4179 checked,
4180 );
4181 }
4182 }
4183 WidgetMutation::SetSelectedIndex { widget_key, index } => {
4184 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4186 let (prev_scroll, prev_index, prev_item_height, prev_user_scrolled) =
4187 match panel.instance_states.get(&widget_key) {
4188 Some(crate::widgets::WidgetInstanceState::List {
4189 scroll_offset,
4190 selected_index,
4191 item_height,
4192 user_scrolled,
4193 }) => (
4194 *scroll_offset,
4195 *selected_index,
4196 *item_height,
4197 *user_scrolled,
4198 ),
4199 _ => (0, -1, 1, false),
4200 };
4201 let user_scrolled = prev_user_scrolled && index == prev_index;
4207 panel.instance_states.insert(
4208 widget_key,
4209 crate::widgets::WidgetInstanceState::List {
4210 scroll_offset: prev_scroll,
4211 selected_index: index,
4212 item_height: prev_item_height,
4213 user_scrolled,
4214 },
4215 );
4216 }
4217 }
4218 WidgetMutation::SetCompletions { widget_key, items } => {
4219 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4228 if let Some(crate::widgets::WidgetInstanceState::Text {
4229 completions,
4230 completion_selected_index,
4231 completion_scroll_offset,
4232 completion_navigated,
4233 ..
4234 }) = panel.instance_states.get_mut(&widget_key)
4235 {
4236 *completions = items;
4237 *completion_selected_index = 0;
4238 *completion_scroll_offset = 0;
4239 *completion_navigated = false;
4244 }
4245 }
4246 }
4247 WidgetMutation::SetItems {
4248 widget_key,
4249 items,
4250 item_keys,
4251 } => {
4252 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4254 crate::widgets::set_list_items_in_spec(
4255 &mut panel.spec,
4256 &widget_key,
4257 items,
4258 item_keys,
4259 );
4260 }
4261 }
4262 WidgetMutation::SetExpandedKeys { widget_key, keys } => {
4263 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4265 let (prev_scroll, prev_sel) = match panel.instance_states.get(&widget_key) {
4266 Some(crate::widgets::WidgetInstanceState::Tree {
4267 scroll_offset,
4268 selected_index,
4269 ..
4270 }) => (*scroll_offset, *selected_index),
4271 _ => (0, -1),
4272 };
4273 let expanded: std::collections::HashSet<String> = keys.into_iter().collect();
4274 panel.instance_states.insert(
4275 widget_key,
4276 crate::widgets::WidgetInstanceState::Tree {
4277 scroll_offset: prev_scroll,
4278 selected_index: prev_sel,
4279 expanded_keys: expanded,
4280 },
4281 );
4282 }
4283 }
4284 WidgetMutation::SetCheckedKeys {
4285 widget_key,
4286 checked,
4287 keys,
4288 } => {
4289 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4297 crate::widgets::set_tree_checked_keys_in_spec(
4298 &mut panel.spec,
4299 &widget_key,
4300 checked,
4301 &keys,
4302 );
4303 }
4304 }
4305 WidgetMutation::AppendTreeNodes {
4306 widget_key,
4307 new_nodes,
4308 new_item_keys,
4309 } => {
4310 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4311 crate::widgets::append_tree_nodes_in_spec(
4312 &mut panel.spec,
4313 &widget_key,
4314 new_nodes,
4315 new_item_keys,
4316 );
4317 }
4318 }
4319 WidgetMutation::SetRawEntries {
4320 widget_key,
4321 entries,
4322 } => {
4323 if let Some(panel) = self.widget_registry.get_mut(panel_key) {
4324 crate::widgets::set_raw_entries_in_spec(&mut panel.spec, &widget_key, entries);
4325 }
4326 }
4327 WidgetMutation::SetFocusKey { widget_key } => {
4328 self.widget_registry.set_focus_key(panel_key, widget_key);
4333 }
4334 }
4335
4336 self.rerender_widget_panel(panel_key);
4340 }
4341
4342 fn handle_unmount_widget_panel(&mut self, panel_key: &crate::widgets::PanelKey) {
4343 match self.widget_registry.unmount(panel_key) {
4344 Some(buffer_id) => {
4345 tracing::debug!(
4346 "Unmounted widget panel {} (was rendering into {:?})",
4347 panel_key,
4348 buffer_id
4349 );
4350 }
4355 None => {
4356 tracing::debug!("UnmountWidgetPanel for unknown panel {} ignored", panel_key);
4357 }
4358 }
4359 }
4360
4361 fn handle_mount_floating_widget(
4362 &mut self,
4363 panel_key: crate::widgets::PanelKey,
4364 spec: fresh_core::api::WidgetSpec,
4365 width_pct: u8,
4366 height_pct: u8,
4367 as_dock: bool,
4368 focus_marker: bool,
4369 ) {
4370 let width_pct = width_pct.clamp(1, 100);
4371 let height_pct = height_pct.clamp(1, 100);
4372 let slot = if as_dock {
4375 super::PanelSlot::Dock
4376 } else {
4377 super::PanelSlot::Floating
4378 };
4379 let buffer_id = slot.buffer_id();
4380 if !as_dock && self.dock.as_ref().is_some_and(|f| f.focused) {
4387 self.blur_floating_panel(super::PanelSlot::Dock);
4388 }
4389 let placement = if as_dock {
4390 let width = self
4391 .dock_width
4392 .unwrap_or(32)
4393 .clamp(10, self.terminal_width.max(20).saturating_sub(20).max(10));
4394 super::PanelPlacement::LeftDock { width_cols: width }
4395 } else {
4396 super::PanelPlacement::Centered
4397 };
4398 if let Some(existing) = self.panel_opt_mut(slot).take() {
4399 if existing.panel_key != panel_key {
4400 let _ = self.widget_registry.unmount(&existing.panel_key);
4401 }
4402 }
4403 *self.panel_opt_mut(slot) = Some(FloatingWidgetState {
4404 panel_key: panel_key.clone(),
4405 width_pct,
4406 height_pct,
4407 placement,
4408 focused: true,
4409 entries: Vec::new(),
4410 focus_cursor: None,
4411 embeds: Vec::new(),
4412 overlays: Vec::new(),
4413 scroll_regions: Vec::new(),
4414 scrollbar_tracks: Vec::new(),
4415 scrollbar_mouse: Default::default(),
4416 scrollbar_drag_key: None,
4417 last_inner_rect: None,
4418 scrollbar_hover_zones: Vec::new(),
4419 scrollbar_zone_hovered: false,
4420 fullscreen: false,
4421 focus_marker,
4422 });
4423 let prev = std::collections::HashMap::new();
4424 let prev_focus = String::new();
4425 let panel_width = self.floating_panel_inner_width(slot);
4426 let out = super::widget_runtime::render_floating_spec(
4427 focus_marker,
4428 &spec,
4429 &prev,
4430 &prev_focus,
4431 panel_width,
4432 );
4433 let focus_cursor = out.focus_cursor;
4434 let entries = out.entries;
4435 let embeds = out.embeds;
4436 let overlays = out.overlays;
4437 let scroll_regions = out.scroll_regions;
4438 self.widget_registry.mount(
4439 panel_key.clone(),
4440 buffer_id,
4441 spec,
4442 out.hits,
4443 out.instance_states,
4444 out.focus_key,
4445 out.tabbable,
4446 );
4447 if let Some(fwp) = self.panel_mut(slot) {
4448 fwp.entries = entries;
4449 fwp.focus_cursor = focus_cursor;
4450 fwp.embeds = embeds;
4451 fwp.overlays = overlays;
4452 fwp.scroll_regions = scroll_regions;
4453 }
4454 tracing::debug!(
4455 "Mounted floating widget panel {} ({}%x{}%)",
4456 panel_key,
4457 width_pct,
4458 height_pct
4459 );
4460
4461 if as_dock {
4466 self.relayout();
4467 }
4468 }
4469
4470 fn handle_update_floating_widget(
4471 &mut self,
4472 panel_key: &crate::widgets::PanelKey,
4473 spec: fresh_core::api::WidgetSpec,
4474 ) {
4475 let Some(slot) = self.slot_of_panel(panel_key) else {
4476 tracing::debug!(
4477 "UpdateFloatingWidget for unknown / mismatched panel {} ignored",
4478 panel_key
4479 );
4480 return;
4481 };
4482 let prev = self
4483 .widget_registry
4484 .instance_states(panel_key)
4485 .cloned()
4486 .unwrap_or_default();
4487 let prev_focus = self
4488 .widget_registry
4489 .focus_key(panel_key)
4490 .map(|s| s.to_string())
4491 .unwrap_or_default();
4492 let panel_width = self.floating_panel_inner_width(slot);
4493 let focus_marker = self.panel(slot).map(|f| f.focus_marker).unwrap_or(false);
4494 let out = super::widget_runtime::render_floating_spec(
4495 focus_marker,
4496 &spec,
4497 &prev,
4498 &prev_focus,
4499 panel_width,
4500 );
4501 let focus_cursor = out.focus_cursor;
4502 let entries = out.entries;
4503 let embeds = out.embeds;
4504 let overlays = out.overlays;
4505 let scroll_regions = out.scroll_regions;
4506 if self
4507 .widget_registry
4508 .update(
4509 panel_key,
4510 spec,
4511 out.hits,
4512 out.instance_states,
4513 out.focus_key,
4514 out.tabbable,
4515 )
4516 .is_err()
4517 {
4518 tracing::debug!(
4519 "UpdateFloatingWidget for unknown panel {} ignored (not in registry)",
4520 panel_key
4521 );
4522 return;
4523 }
4524 if let Some(fwp) = self.panel_mut(slot) {
4525 fwp.entries = entries;
4526 fwp.focus_cursor = focus_cursor;
4527 fwp.embeds = embeds;
4528 fwp.overlays = overlays;
4529 fwp.scroll_regions = scroll_regions;
4530 }
4531 }
4532
4533 fn handle_unmount_floating_widget(&mut self, panel_key: &crate::widgets::PanelKey) {
4534 let Some(slot) = self.slot_of_panel(panel_key) else {
4535 tracing::debug!(
4536 "UnmountFloatingWidget for unknown / mismatched panel {} ignored",
4537 panel_key
4538 );
4539 return;
4540 };
4541 *self.panel_opt_mut(slot) = None;
4542 let _ = self.widget_registry.unmount(panel_key);
4543 if slot == super::PanelSlot::Dock {
4554 self.request_full_redraw();
4555 }
4556 self.relayout();
4572 tracing::debug!("Unmounted floating widget panel {}", panel_key);
4573 }
4574
4575 fn handle_floating_panel_control(
4578 &mut self,
4579 panel_key: &crate::widgets::PanelKey,
4580 op: &str,
4581 arg: f64,
4582 ) {
4583 let Some(slot) = self.slot_of_panel(panel_key) else {
4584 tracing::warn!("FloatingPanelControl for unknown/mismatched panel {panel_key} ignored");
4585 return;
4586 };
4587 if op == "blur" {
4590 self.blur_floating_panel(slot);
4591 return;
4592 }
4593 let max_cols = self.terminal_width.max(20).saturating_sub(20).max(10);
4598 let persisted = self.dock_width;
4599 let Some(fwp) = self.panel_mut(slot) else {
4600 return;
4601 };
4602 let geometry_changed = match op {
4605 "dock" => {
4606 let requested = persisted.unwrap_or(arg as u16);
4607 let width_cols = requested.clamp(10, max_cols);
4608 fwp.placement = super::PanelPlacement::LeftDock { width_cols };
4609 fwp.focused = true;
4610 true
4611 }
4612 "dock_width" => {
4618 if let super::PanelPlacement::LeftDock { .. } = fwp.placement {
4619 let requested = persisted.unwrap_or(arg as u16);
4620 let width_cols = requested.clamp(10, max_cols);
4621 fwp.placement = super::PanelPlacement::LeftDock { width_cols };
4622 true
4623 } else {
4624 false
4625 }
4626 }
4627 "center" => {
4628 fwp.placement = super::PanelPlacement::Centered;
4629 fwp.focused = true;
4630 true
4631 }
4632 "anchor" => {
4638 let packed = arg.max(0.0) as u64;
4639 let x = (packed & 0xFFFF) as u16;
4640 let y = ((packed >> 16) & 0xFFFF) as u16;
4641 fwp.placement = super::PanelPlacement::Anchored { x, y };
4642 fwp.focused = true;
4643 fwp.fullscreen = false;
4644 false
4645 }
4646 "focus" => {
4647 fwp.focused = true;
4648 false
4649 }
4650 "fullscreen" => {
4656 fwp.fullscreen = arg != 0.0;
4657 false
4658 }
4659 other => {
4660 tracing::warn!("FloatingPanelControl: unknown op {other:?}");
4661 false
4662 }
4663 };
4664 if geometry_changed {
4668 self.relayout();
4669 }
4670 }
4671
4672 fn handle_get_text_properties_at_cursor(&self, buffer_id: BufferId) {
4673 if let Some(state) = self
4674 .windows
4675 .get(&self.active_window)
4676 .map(|w| &w.buffers)
4677 .expect("active window present")
4678 .get(&buffer_id)
4679 {
4680 let cursor_pos = self
4681 .windows
4682 .get(&self.active_window)
4683 .and_then(|w| w.buffers.splits())
4684 .map(|(_, vs)| vs)
4685 .expect("active window must have a populated split layout")
4686 .values()
4687 .find_map(|vs| vs.buffer_state(buffer_id))
4688 .map(|bs| bs.cursors.primary().position)
4689 .unwrap_or(0);
4690 let properties = state.text_properties.get_at(cursor_pos);
4691 tracing::debug!(
4692 "Text properties at cursor in {:?}: {} properties found",
4693 buffer_id,
4694 properties.len()
4695 );
4696 }
4698 }
4699
4700 fn handle_set_context(&mut self, name: String, active: bool) {
4701 if active {
4702 self.active_window_mut()
4703 .active_custom_contexts
4704 .insert(name.clone());
4705 tracing::debug!("Set custom context: {}", name);
4706 } else {
4707 self.active_window_mut()
4708 .active_custom_contexts
4709 .remove(&name);
4710 tracing::debug!("Unset custom context: {}", name);
4711 }
4712 }
4713
4714 fn handle_disable_lsp_for_language(&mut self, language: String) {
4715 tracing::info!("Disabling LSP for language: {}", language);
4716 let __active_id = self.active_window;
4717 if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
4718 lsp.shutdown_server(&language);
4719 tracing::info!("Stopped LSP server for {}", language);
4720 }
4721 if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
4722 for c in lsp_configs.as_mut_slice() {
4723 c.enabled = false;
4724 c.auto_start = false;
4725 }
4726 tracing::info!("Disabled LSP config for {}", language);
4727 }
4728 if let Err(e) = self.save_config() {
4729 tracing::error!("Failed to save config: {}", e);
4730 self.active_window_mut().status_message = Some(format!(
4731 "LSP disabled for {} (config save failed)",
4732 language
4733 ));
4734 } else {
4735 self.active_window_mut().status_message =
4736 Some(format!("LSP disabled for {}", language));
4737 }
4738 self.active_window_mut().warning_domains.lsp.clear();
4739 }
4740
4741 fn handle_restart_lsp_for_language(&mut self, language: String) {
4742 tracing::info!("Plugin restarting LSP for language: {}", language);
4743 let file_path = self
4744 .active_window()
4745 .buffer_metadata
4746 .get(&self.active_buffer())
4747 .and_then(|meta| meta.file_path().cloned());
4748 let __active_id = self.active_window;
4749 let success = if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
4750 let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
4751 self.active_window_mut().status_message = Some(msg);
4752 ok
4753 } else {
4754 self.active_window_mut().status_message = Some("No LSP manager available".to_string());
4755 false
4756 };
4757 if success {
4758 self.reopen_buffers_for_language(&language);
4759 }
4760 }
4761
4762 fn handle_set_lsp_root_uri(&mut self, language: String, uri: String) {
4763 tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
4764 match uri.parse::<lsp_types::Uri>() {
4765 Ok(parsed_uri) => {
4766 let __active_id = self.active_window;
4767 if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
4768 let restarted = lsp.set_language_root_uri(&language, parsed_uri);
4769 if restarted {
4770 self.active_window_mut().status_message = Some(format!(
4771 "LSP root updated for {} (restarting server)",
4772 language
4773 ));
4774 } else {
4775 self.active_window_mut().status_message =
4776 Some(format!("LSP root set for {}", language));
4777 }
4778 }
4779 }
4780 Err(e) => {
4781 tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
4782 self.active_window_mut().status_message =
4783 Some(format!("Invalid LSP root URI: {}", e));
4784 }
4785 }
4786 }
4787
4788 fn handle_create_scroll_sync_group(
4789 &mut self,
4790 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
4791 left_split: SplitId,
4792 right_split: SplitId,
4793 ) {
4794 let success = self
4795 .active_window_mut()
4796 .scroll_sync_manager
4797 .create_group_with_id(group_id, left_split, right_split);
4798 if success {
4799 tracing::debug!(
4800 "Created scroll sync group {} for splits {:?} and {:?}",
4801 group_id,
4802 left_split,
4803 right_split
4804 );
4805 } else {
4806 tracing::warn!(
4807 "Failed to create scroll sync group {} (ID already exists)",
4808 group_id
4809 );
4810 }
4811 }
4812
4813 fn handle_set_scroll_sync_anchors(
4814 &mut self,
4815 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
4816 anchors: Vec<(usize, usize)>,
4817 ) {
4818 use crate::view::scroll_sync::SyncAnchor;
4819 let anchor_count = anchors.len();
4820 let sync_anchors: Vec<SyncAnchor> = anchors
4821 .into_iter()
4822 .map(|(left_line, right_line)| SyncAnchor {
4823 left_line,
4824 right_line,
4825 })
4826 .collect();
4827 self.active_window_mut()
4828 .scroll_sync_manager
4829 .set_anchors(group_id, sync_anchors);
4830 tracing::debug!(
4831 "Set {} anchors for scroll sync group {}",
4832 anchor_count,
4833 group_id
4834 );
4835 }
4836
4837 fn handle_remove_scroll_sync_group(
4838 &mut self,
4839 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
4840 ) {
4841 if self
4842 .active_window_mut()
4843 .scroll_sync_manager
4844 .remove_group(group_id)
4845 {
4846 tracing::debug!("Removed scroll sync group {}", group_id);
4847 } else {
4848 tracing::warn!("Scroll sync group {} not found", group_id);
4849 }
4850 }
4851
4852 fn handle_create_buffer_group(
4853 &mut self,
4854 name: String,
4855 mode: String,
4856 layout_json: String,
4857 request_id: Option<u64>,
4858 ) {
4859 match self.create_buffer_group(name, mode, layout_json) {
4860 Ok(result) => {
4861 if let Some(req_id) = request_id {
4862 let json = serde_json::to_string(&result).unwrap_or_default();
4863 self.plugin_manager
4864 .read()
4865 .unwrap()
4866 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
4867 }
4868 }
4869 Err(e) => {
4870 tracing::error!("Failed to create buffer group: {}", e);
4871 }
4872 }
4873 }
4874
4875 fn handle_send_terminal_input(
4876 &mut self,
4877 terminal_id: crate::services::terminal::TerminalId,
4878 data: String,
4879 ) {
4880 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
4881 handle.write(data.as_bytes());
4882 tracing::trace!(
4883 "Plugin sent {} bytes to terminal {:?}",
4884 data.len(),
4885 terminal_id
4886 );
4887 } else {
4888 tracing::warn!(
4889 "Plugin tried to send input to non-existent terminal {:?}",
4890 terminal_id
4891 );
4892 }
4893 }
4894
4895 fn handle_close_terminal(&mut self, terminal_id: crate::services::terminal::TerminalId) {
4896 let buffer_to_close = self
4897 .active_window()
4898 .terminal_buffers
4899 .iter()
4900 .find(|(_, &tid)| tid == terminal_id)
4901 .map(|(&bid, _)| bid);
4902 if let Some(buffer_id) = buffer_to_close {
4903 if let Err(e) = self.close_buffer(buffer_id) {
4904 tracing::warn!("Failed to close terminal buffer: {}", e);
4905 }
4906 tracing::info!("Plugin closed terminal {:?}", terminal_id);
4907 } else {
4908 self.active_window_mut().terminal_manager.close(terminal_id);
4909 tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
4910 }
4911 }
4912
4913 fn handle_signal_window(&mut self, id: fresh_core::WindowId, signal: &str) {
4921 let Some(window) = self.windows.get_mut(&id) else {
4922 tracing::warn!("Plugin SignalWindow targeted unknown window {:?}", id);
4923 return;
4924 };
4925 let results = window.process_groups.signal_all(signal);
4926 for (entry, result) in results {
4927 match result {
4928 Ok(true) => tracing::info!(
4929 "SignalWindow {:?}: {} → pid {} ({})",
4930 id,
4931 signal,
4932 entry.leader_pid,
4933 entry.label
4934 ),
4935 Ok(false) => tracing::debug!(
4936 "SignalWindow {:?}: pid {} ({}) already exited",
4937 id,
4938 entry.leader_pid,
4939 entry.label
4940 ),
4941 Err(e) => tracing::warn!(
4942 "SignalWindow {:?}: pid {} ({}): {}",
4943 id,
4944 entry.leader_pid,
4945 entry.label,
4946 e
4947 ),
4948 }
4949 }
4950 }
4951}
4952
4953fn clamp_buffer_text_range(start: usize, end: usize, len: usize) -> (usize, usize) {
4964 let end = end.min(len);
4965 let start = start.min(end);
4966 (start, end)
4967}
4968
4969#[cfg(test)]
4970mod tests {
4971 use tokio::io::{AsyncReadExt, BufReader};
4984 use tokio::process::Command as TokioCommand;
4985 use tokio::time::{timeout, Duration};
4986
4987 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4998 async fn kill_via_oneshot_terminates_long_running_child() {
4999 let mut cmd = TokioCommand::new("sleep");
5000 cmd.args(["30"]);
5001 cmd.stdout(std::process::Stdio::piped());
5002 cmd.stderr(std::process::Stdio::piped());
5003
5004 let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
5005 let pid = child.id().expect("child has a pid");
5006
5007 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
5008 let stdout_pipe = child.stdout.take();
5009 let stderr_pipe = child.stderr.take();
5010
5011 let stdout_fut = async {
5012 let mut buf = String::new();
5013 if let Some(s) = stdout_pipe {
5014 #[allow(clippy::let_underscore_must_use)]
5015 let _ = BufReader::new(s).read_to_string(&mut buf).await;
5016 }
5017 buf
5018 };
5019 let stderr_fut = async {
5020 let mut buf = String::new();
5021 if let Some(s) = stderr_pipe {
5022 #[allow(clippy::let_underscore_must_use)]
5023 let _ = BufReader::new(s).read_to_string(&mut buf).await;
5024 }
5025 buf
5026 };
5027 let wait_fut = async {
5028 tokio::select! {
5029 status = child.wait() => {
5030 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
5031 }
5032 _ = &mut kill_rx => {
5033 #[allow(clippy::let_underscore_must_use)]
5034 let _ = child.start_kill();
5035 child
5036 .wait()
5037 .await
5038 .map(|s| s.code().unwrap_or(-1))
5039 .unwrap_or(-1)
5040 }
5041 }
5042 };
5043
5044 tokio::time::sleep(Duration::from_millis(50)).await;
5049 kill_tx.send(()).expect("kill channel send");
5050
5051 let result = timeout(Duration::from_secs(5), async {
5052 tokio::join!(stdout_fut, stderr_fut, wait_fut)
5053 })
5054 .await;
5055
5056 let (_stdout, _stderr, exit_code) = result.expect(
5057 "kill path must resolve within 5s — if this times out the \
5058 select! arm order or kill-then-wait logic is broken",
5059 );
5060 assert_ne!(
5072 exit_code, 0,
5073 "killed child must exit non-success (got 0 — did the \
5074 kill arm fire too late, or did sleep somehow complete?)"
5075 );
5076
5077 #[cfg(unix)]
5086 {
5087 let still_alive = std::process::Command::new("kill")
5088 .args(["-0", &pid.to_string()])
5089 .status()
5090 .map(|s| s.success())
5091 .unwrap_or(false);
5092 assert!(
5093 !still_alive,
5094 "process {pid} must be reaped after wait() — a still-\
5095 alive check means the kill path leaked the child"
5096 );
5097 }
5098 #[cfg(not(unix))]
5099 {
5100 let _ = pid;
5103 }
5104 }
5105
5106 use super::clamp_buffer_text_range;
5107
5108 #[test]
5109 fn clamp_text_range_passes_through_in_bounds() {
5110 assert_eq!(clamp_buffer_text_range(0, 165, 165), (0, 165));
5111 assert_eq!(clamp_buffer_text_range(10, 50, 165), (10, 50));
5112 }
5113
5114 #[test]
5120 fn clamp_text_range_clamps_stale_end_past_buffer() {
5121 assert_eq!(clamp_buffer_text_range(0, 165_003, 165_002), (0, 165_002));
5122 }
5123
5124 #[test]
5125 fn clamp_text_range_pins_overlarge_start_to_empty() {
5126 assert_eq!(clamp_buffer_text_range(200, 250, 165), (165, 165));
5128 }
5129}
5130
5131impl Window {
5132 #[cfg(feature = "plugins")]
5147 pub(crate) fn populate_plugin_state_snapshot(
5148 &mut self,
5149 snapshot: &mut fresh_core::api::EditorStateSnapshot,
5150 ) {
5151 use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
5152
5153 let current_gen = self.resources.grammar_registry.catalog_gen();
5159 if snapshot.last_grammar_gen != current_gen {
5160 snapshot.available_grammars = self
5161 .resources
5162 .grammar_registry
5163 .available_grammar_info()
5164 .into_iter()
5165 .map(|g| fresh_core::api::GrammarInfoSnapshot {
5166 name: g.name,
5167 source: g.source.to_string(),
5168 file_extensions: g.file_extensions,
5169 short_name: g.short_name,
5170 })
5171 .collect();
5172 snapshot.last_grammar_gen = current_gen;
5173 }
5174
5175 snapshot.active_buffer_id = self.active_buffer();
5176
5177 let (mgr_ref, vs_ref) = self
5178 .buffers
5179 .splits()
5180 .expect("active window must have a populated split layout");
5181 let active_split = mgr_ref.active_split();
5182 snapshot.active_split_id = active_split.0 .0;
5183
5184 snapshot.buffers.clear();
5186 snapshot.buffer_saved_diffs.clear();
5187 snapshot.buffer_cursor_positions.clear();
5188 snapshot.buffer_text_properties.clear();
5189
5190 let active_vs_opt = vs_ref.get(&active_split);
5191 for (buffer_id, state) in &self.buffers {
5192 let is_virtual = self
5193 .buffer_metadata
5194 .get(buffer_id)
5195 .map(|m| m.is_virtual())
5196 .unwrap_or(false);
5197 let view_mode = active_vs_opt
5202 .and_then(|vs| vs.buffer_state(*buffer_id))
5203 .map(|bs| match bs.view_mode {
5204 crate::state::ViewMode::Source => "source",
5205 crate::state::ViewMode::PageView => "compose",
5206 })
5207 .unwrap_or("source");
5208 let compose_width = active_vs_opt
5209 .and_then(|vs| vs.buffer_state(*buffer_id))
5210 .and_then(|bs| bs.compose_width);
5211 let is_composing_in_any_split = vs_ref.values().any(|vs| {
5212 vs.buffer_state(*buffer_id)
5213 .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
5214 .unwrap_or(false)
5215 });
5216 let is_preview = self.is_buffer_preview(*buffer_id);
5217 let splits: Vec<fresh_core::SplitId> = mgr_ref
5223 .splits_for_buffer(*buffer_id)
5224 .into_iter()
5225 .map(|leaf_id| leaf_id.0)
5226 .collect();
5227 let buffer_info = BufferInfo {
5228 id: *buffer_id,
5229 path: state.buffer.file_path().map(|p| p.to_path_buf()),
5230 modified: state.buffer.is_modified(),
5231 length: state.buffer.len(),
5232 is_virtual,
5233 editing_disabled: state.editing_disabled,
5234 view_mode: view_mode.to_string(),
5235 is_composing_in_any_split,
5236 compose_width,
5237 language: state.language.clone(),
5238 is_preview,
5239 splits,
5240 };
5241 snapshot.buffers.insert(*buffer_id, buffer_info);
5242
5243 let diff = {
5244 let diff = state.buffer.diff_since_saved();
5245 BufferSavedDiff {
5246 equal: diff.equal,
5247 byte_ranges: diff.byte_ranges.clone(),
5248 }
5249 };
5250 snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
5251
5252 let is_hidden = self
5261 .buffer_metadata
5262 .get(buffer_id)
5263 .is_some_and(|m| m.hidden_from_tabs);
5264 let source_split = vs_ref.iter().find(|(split_id, vs)| {
5265 vs.keyed_states.contains_key(buffer_id)
5266 && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
5267 });
5268 let cursor_pos = source_split
5269 .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
5270 .map(|bs| bs.cursors.primary().position)
5271 .unwrap_or(0);
5272 tracing::trace!(
5273 "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
5274 buffer_id,
5275 cursor_pos,
5276 source_split.map(|(id, _)| *id),
5277 );
5278 snapshot
5279 .buffer_cursor_positions
5280 .insert(*buffer_id, cursor_pos);
5281
5282 if !state.text_properties.is_empty() {
5284 snapshot
5285 .buffer_text_properties
5286 .insert(*buffer_id, state.text_properties.all().to_vec());
5287 }
5288 }
5289
5290 let active_buf_id = snapshot.active_buffer_id;
5301 let active_split_id = self.effective_active_pair().0;
5302 self.buffers
5303 .with_all_mut(|buffers_mut, mgr, vs_map| {
5304 let _ = mgr; if let Some(active_vs) = vs_map.get(&active_split_id) {
5306 let active_cursors = &active_vs.cursors;
5308 let primary = active_cursors.primary();
5309 let primary_position = primary.position;
5310 let primary_selection = primary.selection_range();
5311
5312 let line_of = |offset: usize| -> Option<usize> {
5318 buffers_mut.get(&active_buf_id).and_then(|state| {
5319 if state.buffer.line_count().is_some() {
5320 Some(state.buffer.get_line_number(offset))
5321 } else {
5322 None
5323 }
5324 })
5325 };
5326
5327 snapshot.primary_cursor = Some(CursorInfo {
5328 position: primary_position,
5329 selection: primary_selection.clone(),
5330 line: line_of(primary_position),
5331 });
5332
5333 snapshot.all_cursors = active_cursors
5334 .iter()
5335 .map(|(_, cursor)| CursorInfo {
5336 position: cursor.position,
5337 selection: cursor.selection_range(),
5338 line: line_of(cursor.position),
5339 })
5340 .collect();
5341
5342 if let Some(range) = primary_selection {
5344 if let Some(active_state) = buffers_mut.get_mut(&active_buf_id) {
5345 snapshot.selected_text =
5346 Some(active_state.get_text_range(range.start, range.end));
5347 }
5348 }
5349
5350 let top_line = buffers_mut.get(&active_buf_id).and_then(|state| {
5352 if state.buffer.line_count().is_some() {
5353 Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
5354 } else {
5355 None
5356 }
5357 });
5358 snapshot.viewport = Some(ViewportInfo {
5359 top_byte: active_vs.viewport.top_byte,
5360 top_line,
5361 left_column: active_vs.viewport.left_column,
5362 width: active_vs.viewport.width,
5363 height: active_vs.viewport.height,
5364 });
5365 } else {
5366 snapshot.primary_cursor = None;
5367 snapshot.all_cursors.clear();
5368 snapshot.viewport = None;
5369 snapshot.selected_text = None;
5370 }
5371
5372 snapshot.splits.clear();
5374 for (leaf_id, vs) in vs_map.iter() {
5375 let buf_id = vs.active_buffer;
5376 let top_line = buffers_mut.get(&buf_id).and_then(|state| {
5377 if state.buffer.line_count().is_some() {
5378 Some(state.buffer.get_line_number(vs.viewport.top_byte))
5379 } else {
5380 None
5381 }
5382 });
5383 snapshot.splits.push(fresh_core::api::SplitSnapshot {
5384 split_id: leaf_id.0 .0,
5385 buffer_id: buf_id,
5386 viewport: ViewportInfo {
5387 top_byte: vs.viewport.top_byte,
5388 top_line,
5389 left_column: vs.viewport.left_column,
5390 width: vs.viewport.width,
5391 height: vs.viewport.height,
5392 },
5393 });
5394 }
5395 })
5396 .expect("active window must have a populated split layout");
5397
5398 snapshot.active_session_plugin_states = self.plugin_state.clone();
5404 snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
5409 snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
5410
5411 snapshot.editor_mode = self.editor_mode.clone();
5413
5414 let active_split_id_u64 = active_split_id.0 .0;
5419 let split_changed = snapshot.plugin_view_states_split != active_split_id_u64;
5420 if split_changed {
5421 snapshot.plugin_view_states.clear();
5422 snapshot.plugin_view_states_split = active_split_id_u64;
5423 }
5424
5425 {
5427 let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
5428 snapshot
5429 .plugin_view_states
5430 .retain(|bid, _| open_bids.contains(bid));
5431 }
5432
5433 if let Some(vs_map) = self.buffers.split_view_states() {
5435 if let Some(active_vs) = vs_map.get(&active_split_id) {
5436 for (buffer_id, buf_state) in &active_vs.keyed_states {
5437 if !buf_state.plugin_state.is_empty() {
5438 let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
5439 for (key, value) in &buf_state.plugin_state {
5440 entry.entry(key.clone()).or_insert_with(|| value.clone());
5441 }
5442 }
5443 }
5444 }
5445 }
5446
5447 snapshot.has_active_search = self.search_state.is_some();
5449 }
5450}
5451
5452