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, FLOATING_PANEL_BUFFER_ID};
26
27#[derive(Debug, Clone)]
37struct FocusedText {
38 value: String,
39 cursor: usize,
40 scroll: u32,
41 multiline: bool,
42}
43
44impl FocusedText {
45 fn value(&self) -> &str {
46 &self.value
47 }
48 fn cursor(&self) -> usize {
49 self.cursor
50 }
51}
52
53fn buffer_line_byte_offset(
58 content: &str,
59 buffer_len: usize,
60 line: usize,
61 want_end: bool,
62) -> Option<usize> {
63 if !want_end && line == 0 {
64 return Some(0);
65 }
66 let mut current_line = 0usize;
67 for (byte_idx, c) in content.char_indices() {
68 if c == '\n' {
69 if want_end && current_line == line {
70 return Some(byte_idx);
71 }
72 current_line += 1;
73 if !want_end && current_line == line {
74 return Some(byte_idx + 1);
75 }
76 }
77 }
78 if want_end && current_line == line {
79 Some(buffer_len)
80 } else {
81 None
82 }
83}
84
85fn find_scrollable_widget_key(spec: &fresh_core::api::WidgetSpec) -> Option<String> {
93 use fresh_core::api::WidgetSpec;
94 match spec {
95 WidgetSpec::Tree { key: Some(k), .. } | WidgetSpec::List { key: Some(k), .. }
96 if !k.is_empty() =>
97 {
98 return Some(k.clone());
99 }
100 _ => {}
101 }
102 spec.children().find_map(find_scrollable_widget_key)
103}
104
105fn collect_visible_tree_indices(
106 nodes: &[fresh_core::api::TreeNode],
107 item_keys: &[String],
108 expanded: &std::collections::HashSet<String>,
109) -> Vec<usize> {
110 let mut ancestor_open: Vec<bool> = Vec::new();
111 let mut visible: Vec<usize> = Vec::with_capacity(nodes.len());
112 for (i, node) in nodes.iter().enumerate() {
113 let depth = node.depth as usize;
114 ancestor_open.truncate(depth);
115 if ancestor_open.iter().all(|open| *open) {
116 visible.push(i);
117 }
118 let key = item_keys.get(i).cloned().unwrap_or_default();
119 let is_open = if node.has_children {
120 !key.is_empty() && expanded.contains(&key)
121 } else {
122 true
123 };
124 ancestor_open.push(is_open);
125 }
126 visible
127}
128
129impl Editor {
130 #[cfg(feature = "plugins")]
139 pub(super) fn update_plugin_state_snapshot(&mut self) {
140 let Some(snapshot_handle) = self.plugin_manager.read().unwrap().state_snapshot_handle()
141 else {
142 return;
143 };
144 let mut snapshot = snapshot_handle.write().unwrap();
145
146 self.active_window_mut()
147 .populate_plugin_state_snapshot(&mut snapshot);
148
149 snapshot.clipboard = self.clipboard.get_internal().to_string();
153 snapshot.working_dir = self.working_dir.clone();
154
155 snapshot.authority_label = self.authority.display_label.clone();
162
163 let mut session_infos: Vec<fresh_core::api::WindowInfo> = self
169 .windows
170 .values()
171 .map(|s| fresh_core::api::WindowInfo {
172 id: s.id,
173 label: s.label.clone(),
174 root: s.root.clone(),
175 })
176 .collect();
177 session_infos.sort_by_key(|s| s.id.0);
178 snapshot.windows = session_infos;
179 snapshot.active_window_id = self.active_window;
180
181 if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
190 let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
191 self.config_cached_json = Arc::new(json);
192 self.config_snapshot_anchor = Arc::clone(&self.config);
193 }
194 snapshot.config = Arc::clone(&self.config_cached_json);
195
196 snapshot.user_config = Arc::clone(&self.user_config_raw);
199
200 for (plugin_name, state_map) in &self.plugin_global_state {
203 let entry = snapshot
204 .plugin_global_states
205 .entry(plugin_name.clone())
206 .or_default();
207 for (key, value) in state_map {
208 entry.entry(key.clone()).or_insert_with(|| value.clone());
209 }
210 }
211 }
212
213 pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
215 match command {
216 PluginCommand::InsertText {
218 buffer_id,
219 position,
220 text,
221 } => {
222 self.handle_insert_text(buffer_id, position, text);
223 }
224 PluginCommand::DeleteRange { buffer_id, range } => {
225 self.handle_delete_range(buffer_id, range);
226 }
227 PluginCommand::InsertAtCursor { text } => {
228 self.handle_insert_at_cursor(text);
229 }
230 PluginCommand::DeleteSelection => {
231 self.handle_delete_selection();
232 }
233
234 PluginCommand::AddOverlay {
236 buffer_id,
237 namespace,
238 range,
239 options,
240 } => {
241 self.handle_add_overlay(buffer_id, namespace, range, options);
242 }
243 PluginCommand::RemoveOverlay { buffer_id, handle } => {
244 self.handle_remove_overlay(buffer_id, handle);
245 }
246 PluginCommand::ClearAllOverlays { buffer_id } => {
247 self.handle_clear_all_overlays(buffer_id);
248 }
249 PluginCommand::ClearNamespace {
250 buffer_id,
251 namespace,
252 } => {
253 self.handle_clear_namespace(buffer_id, namespace);
254 }
255 PluginCommand::ClearOverlaysInRange {
256 buffer_id,
257 start,
258 end,
259 } => {
260 self.handle_clear_overlays_in_range(buffer_id, start, end);
261 }
262
263 PluginCommand::AddVirtualText {
265 buffer_id,
266 virtual_text_id,
267 position,
268 text,
269 color,
270 use_bg,
271 before,
272 } => {
273 self.handle_add_virtual_text(
274 buffer_id,
275 virtual_text_id,
276 position,
277 text,
278 color,
279 use_bg,
280 before,
281 );
282 }
283 PluginCommand::AddVirtualTextStyled {
284 buffer_id,
285 virtual_text_id,
286 position,
287 text,
288 fg,
289 bg,
290 bold,
291 italic,
292 before,
293 } => {
294 self.handle_add_virtual_text_styled(
295 buffer_id,
296 virtual_text_id,
297 position,
298 text,
299 fg,
300 bg,
301 bold,
302 italic,
303 before,
304 );
305 }
306 PluginCommand::RemoveVirtualText {
307 buffer_id,
308 virtual_text_id,
309 } => {
310 self.handle_remove_virtual_text(buffer_id, virtual_text_id);
311 }
312 PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
313 self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
314 }
315 PluginCommand::ClearVirtualTexts { buffer_id } => {
316 self.handle_clear_virtual_texts(buffer_id);
317 }
318 PluginCommand::AddVirtualLine {
319 buffer_id,
320 position,
321 text,
322 fg_color,
323 bg_color,
324 above,
325 namespace,
326 priority,
327 gutter_glyph,
328 gutter_color,
329 } => {
330 self.handle_add_virtual_line(
331 buffer_id,
332 position,
333 text,
334 fg_color,
335 bg_color,
336 above,
337 namespace,
338 priority,
339 gutter_glyph,
340 gutter_color,
341 );
342 }
343 PluginCommand::ClearVirtualTextNamespace {
344 buffer_id,
345 namespace,
346 } => {
347 self.handle_clear_virtual_text_namespace(buffer_id, namespace);
348 }
349
350 PluginCommand::AddConceal {
352 buffer_id,
353 namespace,
354 start,
355 end,
356 replacement,
357 } => {
358 self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
359 }
360 PluginCommand::ClearConcealNamespace {
361 buffer_id,
362 namespace,
363 } => {
364 self.handle_clear_conceal_namespace(buffer_id, namespace);
365 }
366 PluginCommand::ClearConcealsInRange {
367 buffer_id,
368 start,
369 end,
370 } => {
371 self.handle_clear_conceals_in_range(buffer_id, start, end);
372 }
373
374 PluginCommand::AddFold {
375 buffer_id,
376 start,
377 end,
378 placeholder,
379 } => {
380 self.handle_add_fold(buffer_id, start, end, placeholder);
381 }
382 PluginCommand::ClearFolds { buffer_id } => {
383 self.handle_clear_folds(buffer_id);
384 }
385
386 PluginCommand::AddSoftBreak {
388 buffer_id,
389 namespace,
390 position,
391 indent,
392 } => {
393 self.handle_add_soft_break(buffer_id, namespace, position, indent);
394 }
395 PluginCommand::ClearSoftBreakNamespace {
396 buffer_id,
397 namespace,
398 } => {
399 self.handle_clear_soft_break_namespace(buffer_id, namespace);
400 }
401 PluginCommand::ClearSoftBreaksInRange {
402 buffer_id,
403 start,
404 end,
405 } => {
406 self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
407 }
408
409 PluginCommand::AddMenuItem {
411 menu_label,
412 item,
413 position,
414 } => {
415 self.handle_add_menu_item(menu_label, item, position);
416 }
417 PluginCommand::AddMenu { menu, position } => {
418 self.handle_add_menu(menu, position);
419 }
420 PluginCommand::RemoveMenuItem {
421 menu_label,
422 item_label,
423 } => {
424 self.handle_remove_menu_item(menu_label, item_label);
425 }
426 PluginCommand::RemoveMenu { menu_label } => {
427 self.handle_remove_menu(menu_label);
428 }
429
430 PluginCommand::FocusSplit { split_id } => {
432 self.handle_focus_split(split_id);
433 }
434 PluginCommand::SetSplitBuffer {
435 split_id,
436 buffer_id,
437 } => {
438 self.handle_set_split_buffer(split_id, buffer_id);
439 }
440 PluginCommand::SetSplitScroll { split_id, top_byte } => {
441 self.handle_set_split_scroll(split_id, top_byte);
442 }
443 PluginCommand::RequestHighlights {
444 buffer_id,
445 range,
446 request_id,
447 } => {
448 self.handle_request_highlights(buffer_id, range, request_id);
449 }
450 PluginCommand::CloseSplit { split_id } => {
451 self.handle_close_split(split_id);
452 }
453 PluginCommand::SetSplitRatio { split_id, ratio } => {
454 self.handle_set_split_ratio(split_id, ratio);
455 }
456 PluginCommand::SetSplitLabel { split_id, label } => {
457 self.windows
458 .get_mut(&self.active_window)
459 .and_then(|w| w.split_manager_mut())
460 .expect("active window must have a populated split layout")
461 .set_label(LeafId(split_id), label);
462 }
463 PluginCommand::ClearSplitLabel { split_id } => {
464 self.windows
465 .get_mut(&self.active_window)
466 .and_then(|w| w.split_manager_mut())
467 .expect("active window must have a populated split layout")
468 .clear_label(split_id);
469 }
470 PluginCommand::GetSplitByLabel { label, request_id } => {
471 self.handle_get_split_by_label(label, request_id);
472 }
473 PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
474 self.handle_distribute_splits_evenly();
475 }
476 PluginCommand::SetBufferCursor {
477 buffer_id,
478 position,
479 } => {
480 self.handle_set_buffer_cursor(buffer_id, position);
481 }
482 PluginCommand::SetBufferShowCursors { buffer_id, show } => {
483 self.handle_set_buffer_show_cursors(buffer_id, show);
484 }
485
486 PluginCommand::SetLayoutHints {
488 buffer_id,
489 split_id,
490 range: _,
491 hints,
492 } => {
493 self.handle_set_layout_hints(buffer_id, split_id, hints);
494 }
495 PluginCommand::SetLineNumbers { buffer_id, enabled } => {
496 self.handle_set_line_numbers(buffer_id, enabled);
497 }
498 PluginCommand::SetViewMode { buffer_id, mode } => {
499 self.handle_set_view_mode(buffer_id, &mode);
500 }
501 PluginCommand::SetLineWrap {
502 buffer_id,
503 split_id,
504 enabled,
505 } => {
506 self.handle_set_line_wrap(buffer_id, split_id, enabled);
507 }
508 PluginCommand::SubmitViewTransform {
509 buffer_id,
510 split_id,
511 payload,
512 } => {
513 self.handle_submit_view_transform(buffer_id, split_id, payload);
514 }
515 PluginCommand::ClearViewTransform {
516 buffer_id: _,
517 split_id,
518 } => {
519 self.handle_clear_view_transform(split_id);
520 }
521 PluginCommand::SetViewState {
522 buffer_id,
523 key,
524 value,
525 } => {
526 self.handle_set_view_state(buffer_id, key, value);
527 }
528 PluginCommand::SetGlobalState {
529 plugin_name,
530 key,
531 value,
532 } => {
533 self.handle_set_global_state(plugin_name, key, value);
534 }
535 PluginCommand::SetWindowState {
536 plugin_name,
537 key,
538 value,
539 } => {
540 self.handle_set_session_state(plugin_name, key, value);
541 }
542 PluginCommand::RefreshLines { buffer_id } => {
543 self.handle_refresh_lines(buffer_id);
544 }
545 PluginCommand::RefreshAllLines => {
546 self.handle_refresh_all_lines();
547 }
548 PluginCommand::HookCompleted { .. } => {
549 }
551 PluginCommand::SetLineIndicator {
552 buffer_id,
553 line,
554 namespace,
555 symbol,
556 color,
557 priority,
558 } => {
559 self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
560 }
561 PluginCommand::SetLineIndicators {
562 buffer_id,
563 lines,
564 namespace,
565 symbol,
566 color,
567 priority,
568 } => {
569 self.handle_set_line_indicators(
570 buffer_id, lines, namespace, symbol, color, priority,
571 );
572 }
573 PluginCommand::ClearLineIndicators {
574 buffer_id,
575 namespace,
576 } => {
577 self.handle_clear_line_indicators(buffer_id, namespace);
578 }
579 PluginCommand::SetFileExplorerDecorations {
580 namespace,
581 decorations,
582 } => {
583 self.active_window_mut()
584 .handle_set_file_explorer_decorations(namespace, decorations);
585 }
586 PluginCommand::ClearFileExplorerDecorations { namespace } => {
587 self.active_window_mut()
588 .handle_clear_file_explorer_decorations(&namespace);
589 }
590
591 PluginCommand::SetStatus { message } => {
593 self.handle_set_status(message);
594 }
595 PluginCommand::ApplyTheme { theme_name } => {
596 self.apply_theme(&theme_name);
597 }
598 PluginCommand::OverrideThemeColors { overrides } => {
599 self.handle_override_theme_colors(overrides);
600 }
601 PluginCommand::ReloadConfig => {
602 self.reload_config();
603 }
604 PluginCommand::SetSetting { path, value, .. } => {
605 self.handle_set_setting(path, value);
606 }
607 PluginCommand::ReloadThemes { apply_theme } => {
608 self.reload_themes();
609 if let Some(theme_name) = apply_theme {
610 self.apply_theme(&theme_name);
611 }
612 }
613 PluginCommand::RegisterGrammar {
614 language,
615 grammar_path,
616 extensions,
617 } => {
618 self.handle_register_grammar(language, grammar_path, extensions);
619 }
620 PluginCommand::RegisterLanguageConfig { language, config } => {
621 self.handle_register_language_config(language, config);
622 }
623 PluginCommand::RegisterLspServer { language, config } => {
624 self.handle_register_lsp_server(language, config);
625 }
626 PluginCommand::ReloadGrammars { callback_id } => {
627 self.handle_reload_grammars(callback_id);
628 }
629 PluginCommand::StartPrompt {
630 label,
631 prompt_type,
632 floating_overlay,
633 } => {
634 self.handle_start_prompt(label, prompt_type, floating_overlay);
635 }
636 PluginCommand::StartPromptWithInitial {
637 label,
638 prompt_type,
639 initial_value,
640 floating_overlay,
641 } => {
642 self.handle_start_prompt_with_initial(
643 label,
644 prompt_type,
645 initial_value,
646 floating_overlay,
647 );
648 }
649 PluginCommand::StartPromptAsync {
650 label,
651 initial_value,
652 callback_id,
653 } => {
654 self.handle_start_prompt_async(label, initial_value, callback_id);
655 }
656 PluginCommand::AwaitNextKey { callback_id } => {
657 self.handle_await_next_key(callback_id);
658 }
659 PluginCommand::SetKeyCaptureActive { active } => {
660 self.active_window_mut().key_capture_active = active;
661 if !active {
662 self.active_window_mut().pending_key_capture_buffer.clear();
666 }
667 }
668 PluginCommand::SetPromptSuggestions { suggestions } => {
669 self.handle_set_prompt_suggestions(suggestions);
670 }
671 PluginCommand::SetPromptInputSync { sync } => {
672 if let Some(prompt) = &mut self.active_window_mut().prompt {
673 prompt.sync_input_on_navigate = sync;
674 }
675 }
676 PluginCommand::SetPromptTitle { title } => {
677 if let Some(prompt) = &mut self.active_window_mut().prompt {
678 prompt.title = title;
679 }
680 }
681 PluginCommand::SetPromptFooter { footer } => {
682 if let Some(prompt) = &mut self.active_window_mut().prompt {
683 prompt.footer = footer;
684 }
685 }
686 PluginCommand::SetPromptSelectedIndex { index } => {
687 if let Some(prompt) = &mut self.active_window_mut().prompt {
688 let len = prompt.suggestions.len();
689 if len > 0 {
690 let clamped = (index as usize).min(len - 1);
691 prompt.selected_suggestion = Some(clamped);
692 }
693 }
694 }
695
696 PluginCommand::CreateWindow { root, label } => {
699 if !root.is_absolute() {
700 tracing::warn!(
701 "CreateWindow rejected: root must be absolute, got {:?}",
702 root
703 );
704 } else {
705 let _ = self.create_window_at(root, label);
706 }
707 }
708 PluginCommand::SetActiveWindow { id } => {
709 self.set_active_window(id);
710 }
711 PluginCommand::CloseWindow { id } => {
712 let _ = self.close_window(id);
713 }
714 PluginCommand::PrewarmWindow { id } => {
715 self.prewarm_window(id);
716 }
717
718 PluginCommand::WatchPath {
720 path,
721 recursive,
722 request_id,
723 } => {
724 let result = if let Some(ref bridge) = self.async_bridge {
725 self.file_watcher_manager.watch(bridge, &path, recursive)
726 } else {
727 Err(
728 "watchPath: no async bridge — file watching is unavailable in this build"
729 .to_string(),
730 )
731 };
732 self.last_watch_response_for_test = Some((request_id, result.clone()));
733 self.send_plugin_response(fresh_core::api::PluginResponse::WatchPathRegistered {
734 request_id,
735 result,
736 });
737 }
738 PluginCommand::UnwatchPath { handle } => {
739 self.file_watcher_manager.unwatch(handle);
740 }
741
742 PluginCommand::PreviewWindowInRect { id } => {
743 self.preview_window_id = match id {
747 Some(sid) if sid != self.active_window && self.windows.contains_key(&sid) => {
748 Some(sid)
749 }
750 _ => None,
751 };
752 }
753
754 PluginCommand::RegisterCommand { command } => {
756 self.handle_register_command(command);
757 }
758 PluginCommand::UnregisterCommand { name } => {
759 self.handle_unregister_command(name);
760 }
761 PluginCommand::DefineMode {
762 name,
763 bindings,
764 read_only,
765 allow_text_input,
766 inherit_normal_bindings,
767 plugin_name,
768 } => {
769 self.handle_define_mode(
770 name,
771 bindings,
772 read_only,
773 allow_text_input,
774 inherit_normal_bindings,
775 plugin_name,
776 );
777 }
778
779 PluginCommand::OpenFileInBackground { path, window_id } => {
781 let route_to_inactive = match window_id {
782 Some(id) if id != self.active_window && self.windows.contains_key(&id) => {
783 Some(id)
784 }
785 _ => None,
786 };
787 if let Some(target) = route_to_inactive {
788 self.handle_open_file_in_inactive_session(target, path);
789 } else {
790 self.handle_open_file_in_background(path);
791 }
792 }
793 PluginCommand::OpenFileAtLocation { path, line, column } => {
794 return self.handle_open_file_at_location(path, line, column);
795 }
796 PluginCommand::OpenFileInSplit {
797 split_id,
798 path,
799 line,
800 column,
801 } => {
802 return self.handle_open_file_in_split(split_id, path, line, column);
803 }
804 PluginCommand::ShowBuffer { buffer_id } => {
805 self.handle_show_buffer(buffer_id);
806 }
807 PluginCommand::CloseBuffer { buffer_id } => {
808 self.handle_close_buffer(buffer_id);
809 }
810
811 PluginCommand::StartAnimationArea { id, rect, kind } => {
813 self.handle_start_animation_area(id, rect, kind);
814 }
815 PluginCommand::StartAnimationVirtualBuffer {
816 id,
817 buffer_id,
818 kind,
819 } => {
820 self.handle_start_animation_virtual_buffer(id, buffer_id, kind);
821 }
822 PluginCommand::CancelAnimation { id } => {
823 self.active_window_mut()
824 .animations
825 .cancel(crate::view::animation::AnimationId::from_raw(id));
826 }
827
828 PluginCommand::SendLspRequest {
830 language,
831 method,
832 params,
833 request_id,
834 } => {
835 self.handle_send_lsp_request(language, method, params, request_id);
836 }
837
838 PluginCommand::SetClipboard { text } => {
840 self.handle_set_clipboard(text);
841 }
842
843 PluginCommand::SpawnProcess {
845 command,
846 args,
847 cwd,
848 callback_id,
849 } => {
850 self.handle_spawn_process(command, args, cwd, callback_id);
851 }
852
853 PluginCommand::SpawnHostProcess {
854 command,
855 args,
856 cwd,
857 callback_id,
858 } => {
859 self.handle_spawn_host_process(command, args, cwd, callback_id);
860 }
861
862 PluginCommand::KillHostProcess { process_id } => {
863 self.handle_kill_host_process(process_id);
864 }
865
866 PluginCommand::SetAuthority { payload } => {
867 self.handle_set_authority(payload);
868 }
869
870 PluginCommand::ClearAuthority => {
871 tracing::info!("Plugin cleared authority; restoring local");
872 self.clear_authority();
873 }
874
875 PluginCommand::SetRemoteIndicatorState { state } => {
876 self.handle_set_remote_indicator_state(state);
877 }
878
879 PluginCommand::ClearRemoteIndicatorState => {
880 self.remote_indicator_override = None;
881 }
882
883 PluginCommand::SpawnProcessWait {
884 process_id,
885 callback_id,
886 } => {
887 self.handle_spawn_process_wait(process_id, callback_id);
888 }
889
890 PluginCommand::Delay {
891 callback_id,
892 duration_ms,
893 } => {
894 self.handle_delay(callback_id, duration_ms);
895 }
896
897 PluginCommand::SpawnBackgroundProcess {
898 process_id,
899 command,
900 args,
901 cwd,
902 callback_id,
903 } => {
904 self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
905 }
906
907 PluginCommand::KillBackgroundProcess { process_id } => {
908 self.handle_kill_background_process(process_id);
909 }
910
911 PluginCommand::CreateVirtualBuffer {
913 name,
914 mode,
915 read_only,
916 } => {
917 self.handle_create_virtual_buffer(name, mode, read_only);
918 }
919 PluginCommand::CreateVirtualBufferWithContent {
920 name,
921 mode,
922 read_only,
923 entries,
924 show_line_numbers,
925 show_cursors,
926 editing_disabled,
927 hidden_from_tabs,
928 request_id,
929 } => {
930 self.handle_create_virtual_buffer_with_content(
931 name,
932 mode,
933 read_only,
934 entries,
935 show_line_numbers,
936 show_cursors,
937 editing_disabled,
938 hidden_from_tabs,
939 request_id,
940 );
941 }
942 PluginCommand::CreateVirtualBufferInSplit {
943 name,
944 mode,
945 read_only,
946 entries,
947 ratio,
948 direction,
949 panel_id,
950 show_line_numbers,
951 show_cursors,
952 editing_disabled,
953 line_wrap,
954 before,
955 role,
956 request_id,
957 } => {
958 self.handle_create_virtual_buffer_in_split(
959 name,
960 mode,
961 read_only,
962 entries,
963 ratio,
964 direction,
965 panel_id,
966 show_line_numbers,
967 show_cursors,
968 editing_disabled,
969 line_wrap,
970 before,
971 role,
972 request_id,
973 );
974 }
975 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
976 self.handle_set_virtual_buffer_content(buffer_id, entries);
977 }
978 PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
979 self.handle_get_text_properties_at_cursor(buffer_id);
980 }
981 PluginCommand::CreateVirtualBufferInExistingSplit {
982 name,
983 mode,
984 read_only,
985 entries,
986 split_id,
987 show_line_numbers,
988 show_cursors,
989 editing_disabled,
990 line_wrap,
991 request_id,
992 } => {
993 self.handle_create_virtual_buffer_in_existing_split(
994 name,
995 mode,
996 read_only,
997 entries,
998 split_id,
999 show_line_numbers,
1000 show_cursors,
1001 editing_disabled,
1002 line_wrap,
1003 request_id,
1004 );
1005 }
1006
1007 PluginCommand::SetContext { name, active } => {
1009 self.handle_set_context(name, active);
1010 }
1011
1012 PluginCommand::SetReviewDiffHunks { hunks } => {
1014 self.active_window_mut().review_hunks = hunks;
1015 tracing::debug!(
1016 "Set {} review hunks",
1017 self.active_window_mut().review_hunks.len()
1018 );
1019 }
1020
1021 PluginCommand::ExecuteAction { action_name } => {
1023 self.handle_execute_action(action_name);
1024 }
1025 PluginCommand::ExecuteActions { actions } => {
1026 self.handle_execute_actions(actions);
1027 }
1028 PluginCommand::GetBufferText {
1029 buffer_id,
1030 start,
1031 end,
1032 request_id,
1033 } => {
1034 self.handle_get_buffer_text(buffer_id, start, end, request_id);
1035 }
1036 PluginCommand::GetLineStartPosition {
1037 buffer_id,
1038 line,
1039 request_id,
1040 } => {
1041 self.handle_get_line_start_position(buffer_id, line, request_id);
1042 }
1043 PluginCommand::GetLineEndPosition {
1044 buffer_id,
1045 line,
1046 request_id,
1047 } => {
1048 self.handle_get_line_end_position(buffer_id, line, request_id);
1049 }
1050 PluginCommand::GetBufferLineCount {
1051 buffer_id,
1052 request_id,
1053 } => {
1054 self.handle_get_buffer_line_count(buffer_id, request_id);
1055 }
1056 PluginCommand::ScrollToLineCenter {
1057 split_id,
1058 buffer_id,
1059 line,
1060 } => {
1061 self.handle_scroll_to_line_center(split_id, buffer_id, line);
1062 }
1063 PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1064 self.handle_scroll_buffer_to_line(buffer_id, line);
1065 }
1066 PluginCommand::SetEditorMode { mode } => {
1067 self.handle_set_editor_mode(mode);
1068 }
1069
1070 PluginCommand::ShowActionPopup {
1072 popup_id,
1073 title,
1074 message,
1075 actions,
1076 } => {
1077 self.handle_show_action_popup(popup_id, title, message, actions);
1078 }
1079
1080 PluginCommand::SetLspMenuContributions {
1081 plugin_id,
1082 language,
1083 items,
1084 } => {
1085 self.handle_set_lsp_menu_contributions(plugin_id, language, items);
1086 }
1087
1088 PluginCommand::DisableLspForLanguage { language } => {
1089 self.handle_disable_lsp_for_language(language);
1090 }
1091
1092 PluginCommand::RestartLspForLanguage { language } => {
1093 self.handle_restart_lsp_for_language(language);
1094 }
1095
1096 PluginCommand::SetLspRootUri { language, uri } => {
1097 self.handle_set_lsp_root_uri(language, uri);
1098 }
1099
1100 PluginCommand::CreateScrollSyncGroup {
1102 group_id,
1103 left_split,
1104 right_split,
1105 } => {
1106 self.handle_create_scroll_sync_group(group_id, left_split, right_split);
1107 }
1108 PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1109 self.handle_set_scroll_sync_anchors(group_id, anchors);
1110 }
1111 PluginCommand::RemoveScrollSyncGroup { group_id } => {
1112 self.handle_remove_scroll_sync_group(group_id);
1113 }
1114
1115 PluginCommand::CreateCompositeBuffer {
1117 name,
1118 mode,
1119 layout,
1120 sources,
1121 hunks,
1122 initial_focus_hunk,
1123 request_id,
1124 } => {
1125 self.handle_create_composite_buffer(
1126 name,
1127 mode,
1128 layout,
1129 sources,
1130 hunks,
1131 initial_focus_hunk,
1132 request_id,
1133 );
1134 }
1135 PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1136 self.handle_update_composite_alignment(buffer_id, hunks);
1137 }
1138 PluginCommand::CloseCompositeBuffer { buffer_id } => {
1139 self.active_window_mut().close_composite_buffer(buffer_id);
1140 }
1141 PluginCommand::FlushLayout => {
1142 self.flush_layout();
1143 }
1144 PluginCommand::CompositeNextHunk { buffer_id } => {
1145 let split_id = self
1146 .windows
1147 .get(&self.active_window)
1148 .and_then(|w| w.buffers.splits())
1149 .map(|(mgr, _)| mgr)
1150 .expect("active window must have a populated split layout")
1151 .active_split();
1152 self.active_window_mut()
1153 .composite_next_hunk(split_id, buffer_id);
1154 }
1155 PluginCommand::CompositePrevHunk { buffer_id } => {
1156 let split_id = self
1157 .windows
1158 .get(&self.active_window)
1159 .and_then(|w| w.buffers.splits())
1160 .map(|(mgr, _)| mgr)
1161 .expect("active window must have a populated split layout")
1162 .active_split();
1163 self.active_window_mut()
1164 .composite_prev_hunk(split_id, buffer_id);
1165 }
1166
1167 PluginCommand::CreateBufferGroup {
1169 name,
1170 mode,
1171 layout_json,
1172 request_id,
1173 } => {
1174 self.handle_create_buffer_group(name, mode, layout_json, request_id);
1175 }
1176 PluginCommand::SetPanelContent {
1177 group_id,
1178 panel_name,
1179 entries,
1180 } => {
1181 self.set_panel_content(group_id, panel_name, entries);
1182 }
1183 PluginCommand::CloseBufferGroup { group_id } => {
1184 self.close_buffer_group(group_id);
1185 }
1186 PluginCommand::FocusPanel {
1187 group_id,
1188 panel_name,
1189 } => {
1190 self.focus_panel(group_id, panel_name);
1191 }
1192
1193 PluginCommand::SaveBufferToPath { buffer_id, path } => {
1195 self.handle_save_buffer_to_path(buffer_id, path);
1196 }
1197
1198 #[cfg(feature = "plugins")]
1200 PluginCommand::LoadPlugin { path, callback_id } => {
1201 self.handle_load_plugin(path, callback_id);
1202 }
1203 #[cfg(feature = "plugins")]
1204 PluginCommand::UnloadPlugin { name, callback_id } => {
1205 self.handle_unload_plugin(name, callback_id);
1206 }
1207 #[cfg(feature = "plugins")]
1208 PluginCommand::ReloadPlugin { name, callback_id } => {
1209 self.handle_reload_plugin(name, callback_id);
1210 }
1211 #[cfg(feature = "plugins")]
1212 PluginCommand::ListPlugins { callback_id } => {
1213 self.handle_list_plugins(callback_id);
1214 }
1215 #[cfg(not(feature = "plugins"))]
1217 PluginCommand::LoadPlugin { .. }
1218 | PluginCommand::UnloadPlugin { .. }
1219 | PluginCommand::ReloadPlugin { .. }
1220 | PluginCommand::ListPlugins { .. } => {
1221 tracing::warn!("Plugin management commands require the 'plugins' feature");
1222 }
1223
1224 PluginCommand::CreateTerminal {
1226 cwd,
1227 direction,
1228 ratio,
1229 focus,
1230 persistent,
1231 window_id,
1232 request_id,
1233 } => {
1234 self.handle_create_terminal(
1235 cwd, direction, ratio, focus, persistent, window_id, request_id,
1236 );
1237 }
1238
1239 PluginCommand::SendTerminalInput { terminal_id, data } => {
1240 self.handle_send_terminal_input(terminal_id, data);
1241 }
1242
1243 PluginCommand::CloseTerminal { terminal_id } => {
1244 self.handle_close_terminal(terminal_id);
1245 }
1246
1247 PluginCommand::SignalWindow { id, signal } => {
1248 self.handle_signal_window(id, &signal);
1249 }
1250
1251 PluginCommand::GrepProject {
1252 pattern,
1253 fixed_string,
1254 case_sensitive,
1255 max_results,
1256 whole_words,
1257 callback_id,
1258 } => {
1259 self.handle_grep_project(
1260 pattern,
1261 fixed_string,
1262 case_sensitive,
1263 max_results,
1264 whole_words,
1265 callback_id,
1266 );
1267 }
1268
1269 PluginCommand::BeginSearch {
1270 pattern,
1271 fixed_string,
1272 case_sensitive,
1273 max_results,
1274 whole_words,
1275 handle_id,
1276 } => {
1277 self.handle_begin_search(
1278 pattern,
1279 fixed_string,
1280 case_sensitive,
1281 max_results,
1282 whole_words,
1283 handle_id,
1284 );
1285 }
1286
1287 PluginCommand::ReplaceInBuffer {
1288 file_path,
1289 matches,
1290 replacement,
1291 callback_id,
1292 } => {
1293 self.handle_replace_in_buffer(file_path, matches, replacement, callback_id);
1294 }
1295
1296 PluginCommand::MountWidgetPanel {
1297 panel_id,
1298 buffer_id,
1299 spec,
1300 } => {
1301 self.handle_mount_widget_panel(panel_id, buffer_id, spec);
1302 }
1303
1304 PluginCommand::UpdateWidgetPanel { panel_id, spec } => {
1305 self.handle_update_widget_panel(panel_id, spec);
1306 }
1307
1308 PluginCommand::UnmountWidgetPanel { panel_id } => {
1309 self.handle_unmount_widget_panel(panel_id);
1310 }
1311
1312 PluginCommand::WidgetCommand { panel_id, action } => {
1313 self.handle_widget_command(panel_id, action);
1314 }
1315
1316 PluginCommand::WidgetMutate { panel_id, mutation } => {
1317 self.handle_widget_mutate(panel_id, mutation);
1318 }
1319
1320 PluginCommand::MountFloatingWidget {
1321 panel_id,
1322 spec,
1323 width_pct,
1324 height_pct,
1325 } => {
1326 self.handle_mount_floating_widget(panel_id, spec, width_pct, height_pct);
1327 }
1328
1329 PluginCommand::UpdateFloatingWidget { panel_id, spec } => {
1330 self.handle_update_floating_widget(panel_id, spec);
1331 }
1332
1333 PluginCommand::UnmountFloatingWidget { panel_id } => {
1334 self.handle_unmount_floating_widget(panel_id);
1335 }
1336 }
1337 Ok(())
1338 }
1339
1340 fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1342 if let Some(state) = self
1343 .windows
1344 .get_mut(&self.active_window)
1345 .map(|w| &mut w.buffers)
1346 .expect("active window present")
1347 .get_mut(&buffer_id)
1348 {
1349 match state.buffer.save_to_file(&path) {
1351 Ok(()) => {
1352 if let Err(e) = self.finalize_save(Some(path)) {
1355 tracing::warn!("Failed to finalize save: {}", e);
1356 }
1357 tracing::debug!("Saved buffer {:?} to path", buffer_id);
1358 }
1359 Err(e) => {
1360 self.handle_set_status(format!("Error saving: {}", e));
1361 tracing::error!("Failed to save buffer to path: {}", e);
1362 }
1363 }
1364 } else {
1365 self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1366 tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1367 }
1368 }
1369
1370 #[cfg(feature = "plugins")]
1372 fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1373 match self.plugin_manager.read().unwrap().load_plugin(&path) {
1374 Ok(()) => {
1375 tracing::info!("Loaded plugin from {:?}", path);
1376 self.plugin_manager
1377 .read()
1378 .unwrap()
1379 .resolve_callback(callback_id, "true".to_string());
1380 }
1381 Err(e) => {
1382 tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1383 self.plugin_manager
1384 .read()
1385 .unwrap()
1386 .reject_callback(callback_id, format!("{}", e));
1387 }
1388 }
1389 }
1390
1391 #[cfg(feature = "plugins")]
1393 fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1394 match self.plugin_manager.read().unwrap().unload_plugin(&name) {
1395 Ok(()) => {
1396 tracing::info!("Unloaded plugin: {}", name);
1397 self.plugin_manager
1398 .read()
1399 .unwrap()
1400 .resolve_callback(callback_id, "true".to_string());
1401 }
1402 Err(e) => {
1403 tracing::error!("Failed to unload plugin '{}': {}", name, e);
1404 self.plugin_manager
1405 .read()
1406 .unwrap()
1407 .reject_callback(callback_id, format!("{}", e));
1408 }
1409 }
1410 }
1411
1412 #[cfg(feature = "plugins")]
1414 fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1415 match self.plugin_manager.read().unwrap().reload_plugin(&name) {
1416 Ok(()) => {
1417 tracing::info!("Reloaded plugin: {}", name);
1418 self.plugin_manager
1419 .read()
1420 .unwrap()
1421 .resolve_callback(callback_id, "true".to_string());
1422 }
1423 Err(e) => {
1424 tracing::error!("Failed to reload plugin '{}': {}", name, e);
1425 self.plugin_manager
1426 .read()
1427 .unwrap()
1428 .reject_callback(callback_id, format!("{}", e));
1429 }
1430 }
1431 }
1432
1433 #[cfg(feature = "plugins")]
1435 fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
1436 let plugins = self.plugin_manager.read().unwrap().list_plugins();
1437 let json_array: Vec<serde_json::Value> = plugins
1439 .iter()
1440 .map(|p| {
1441 serde_json::json!({
1442 "name": p.name,
1443 "path": p.path.to_string_lossy(),
1444 "enabled": p.enabled
1445 })
1446 })
1447 .collect();
1448 let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
1449 self.plugin_manager
1450 .read()
1451 .unwrap()
1452 .resolve_callback(callback_id, json_str);
1453 }
1454
1455 fn handle_execute_action(&mut self, action_name: String) {
1457 use crate::input::keybindings::Action;
1458 use std::collections::HashMap;
1459
1460 if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
1462 if let Err(e) = self.handle_action(action) {
1464 tracing::warn!("Failed to execute action '{}': {}", action_name, e);
1465 } else {
1466 tracing::debug!("Executed action: {}", action_name);
1467 }
1468 } else {
1469 tracing::warn!("Unknown action: {}", action_name);
1470 }
1471 }
1472
1473 fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
1476 use crate::input::keybindings::Action;
1477 use std::collections::HashMap;
1478
1479 for action_spec in actions {
1480 if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
1481 for _ in 0..action_spec.count {
1483 if let Err(e) = self.handle_action(action.clone()) {
1484 tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
1485 return; }
1487 }
1488 tracing::debug!(
1489 "Executed action '{}' {} time(s)",
1490 action_spec.action,
1491 action_spec.count
1492 );
1493 } else {
1494 tracing::warn!("Unknown action: {}", action_spec.action);
1495 return; }
1497 }
1498 }
1499
1500 fn handle_get_buffer_text(
1502 &mut self,
1503 buffer_id: BufferId,
1504 start: usize,
1505 end: usize,
1506 request_id: u64,
1507 ) {
1508 let result = if let Some(state) = self
1509 .windows
1510 .get_mut(&self.active_window)
1511 .map(|w| &mut w.buffers)
1512 .expect("active window present")
1513 .get_mut(&buffer_id)
1514 {
1515 let len = state.buffer.len();
1517 if start <= end && end <= len {
1518 Ok(state.get_text_range(start, end))
1519 } else {
1520 Err(format!(
1521 "Invalid range {}..{} for buffer of length {}",
1522 start, end, len
1523 ))
1524 }
1525 } else {
1526 Err(format!("Buffer {:?} not found", buffer_id))
1527 };
1528
1529 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1531 match result {
1532 Ok(text) => {
1533 let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
1535 self.plugin_manager
1536 .read()
1537 .unwrap()
1538 .resolve_callback(callback_id, json);
1539 }
1540 Err(error) => {
1541 self.plugin_manager
1542 .read()
1543 .unwrap()
1544 .reject_callback(callback_id, error);
1545 }
1546 }
1547 }
1548
1549 fn handle_set_editor_mode(&mut self, mode: Option<String>) {
1551 self.active_window_mut().editor_mode = mode.clone();
1552 tracing::debug!("Set editor mode: {:?}", mode);
1553 }
1554
1555 fn resolve_buffer_id(&self, buffer_id: BufferId) -> BufferId {
1557 if buffer_id.0 == 0 {
1558 self.active_buffer()
1559 } else {
1560 buffer_id
1561 }
1562 }
1563
1564 fn resolve_json_callback<T: serde::Serialize>(&mut self, request_id: u64, value: T) {
1566 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1567 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1568 self.plugin_manager
1569 .read()
1570 .unwrap()
1571 .resolve_callback(callback_id, json);
1572 }
1573
1574 fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1576 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1577 let result = self
1578 .windows
1579 .get_mut(&self.active_window)
1580 .map(|w| &mut w.buffers)
1581 .expect("active window present")
1582 .get_mut(&actual_buffer_id)
1583 .and_then(|state| {
1584 let len = state.buffer.len();
1585 let content = state.get_text_range(0, len);
1586 buffer_line_byte_offset(&content, len, line as usize, false)
1587 });
1588 self.resolve_json_callback(request_id, result);
1589 }
1590
1591 fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1594 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1595 let result = self
1596 .windows
1597 .get_mut(&self.active_window)
1598 .map(|w| &mut w.buffers)
1599 .expect("active window present")
1600 .get_mut(&actual_buffer_id)
1601 .and_then(|state| {
1602 let len = state.buffer.len();
1603 let content = state.get_text_range(0, len);
1604 buffer_line_byte_offset(&content, len, line as usize, true)
1605 });
1606 self.resolve_json_callback(request_id, result);
1607 }
1608
1609 fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
1611 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1612
1613 let result = if let Some(state) = self
1614 .windows
1615 .get_mut(&self.active_window)
1616 .map(|w| &mut w.buffers)
1617 .expect("active window present")
1618 .get_mut(&actual_buffer_id)
1619 {
1620 let buffer_len = state.buffer.len();
1621 let content = state.get_text_range(0, buffer_len);
1622
1623 if content.is_empty() {
1625 Some(1) } else {
1627 let newline_count = content.chars().filter(|&c| c == '\n').count();
1628 let ends_with_newline = content.ends_with('\n');
1630 if ends_with_newline {
1631 Some(newline_count)
1632 } else {
1633 Some(newline_count + 1)
1634 }
1635 }
1636 } else {
1637 None
1638 };
1639
1640 self.resolve_json_callback(request_id, result);
1641 }
1642
1643 fn handle_scroll_to_line_center(
1645 &mut self,
1646 split_id: SplitId,
1647 buffer_id: BufferId,
1648 line: usize,
1649 ) {
1650 let actual_split_id = if split_id.0 == 0 {
1651 self.windows
1652 .get(&self.active_window)
1653 .and_then(|w| w.buffers.splits())
1654 .map(|(mgr, _)| mgr)
1655 .expect("active window must have a populated split layout")
1656 .active_split()
1657 } else {
1658 LeafId(split_id)
1659 };
1660 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1661
1662 let viewport_height = if let Some(view_state) = self
1664 .windows
1665 .get(&self.active_window)
1666 .and_then(|w| w.buffers.splits())
1667 .map(|(_, vs)| vs)
1668 .expect("active window must have a populated split layout")
1669 .get(&actual_split_id)
1670 {
1671 view_state.viewport.height as usize
1672 } else {
1673 return;
1674 };
1675
1676 let lines_above = viewport_height / 2;
1678 let target_line = line.saturating_sub(lines_above);
1679
1680 self.active_window_mut().scroll_split_viewport_to(
1681 actual_buffer_id,
1682 actual_split_id,
1683 target_line,
1684 true,
1685 );
1686 }
1687
1688 fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
1698 if !self
1699 .windows
1700 .get(&self.active_window)
1701 .map(|w| &w.buffers)
1702 .expect("active window present")
1703 .contains_key(&buffer_id)
1704 {
1705 return;
1706 }
1707
1708 let mut target_leaves: Vec<LeafId> = Vec::new();
1710
1711 for leaf_id in self
1713 .windows
1714 .get(&self.active_window)
1715 .and_then(|w| w.buffers.splits())
1716 .map(|(mgr, _)| mgr)
1717 .expect("active window must have a populated split layout")
1718 .root()
1719 .leaf_split_ids()
1720 {
1721 if let Some(vs) = self
1722 .windows
1723 .get(&self.active_window)
1724 .and_then(|w| w.buffers.splits())
1725 .map(|(_, vs)| vs)
1726 .expect("active window must have a populated split layout")
1727 .get(&leaf_id)
1728 {
1729 if vs.active_buffer == buffer_id {
1730 target_leaves.push(leaf_id);
1731 }
1732 }
1733 }
1734
1735 for (_group_leaf_id, node) in self.active_window().grouped_subtrees.iter() {
1737 if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
1738 for inner_leaf in layout.leaf_split_ids() {
1739 if let Some(vs) = self
1740 .windows
1741 .get(&self.active_window)
1742 .and_then(|w| w.buffers.splits())
1743 .map(|(_, vs)| vs)
1744 .expect("active window must have a populated split layout")
1745 .get(&inner_leaf)
1746 {
1747 if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
1748 target_leaves.push(inner_leaf);
1749 }
1750 }
1751 }
1752 }
1753 }
1754
1755 if target_leaves.is_empty() {
1756 return;
1757 }
1758
1759 self.active_window_mut()
1760 .scroll_buffer_to_line_in_splits(buffer_id, &target_leaves, line);
1761 }
1762
1763 fn handle_spawn_host_process(
1764 &mut self,
1765 command: String,
1766 args: Vec<String>,
1767 cwd: Option<String>,
1768 callback_id: JsCallbackId,
1769 ) {
1770 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1785 use tokio::io::{AsyncReadExt, BufReader};
1786 use tokio::process::Command as TokioCommand;
1787
1788 let effective_cwd = cwd.or_else(|| {
1789 std::env::current_dir()
1790 .map(|p| p.to_string_lossy().to_string())
1791 .ok()
1792 });
1793 let sender = bridge.sender();
1794 let process_id = callback_id.as_u64();
1795
1796 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
1797 self.host_process_handles.insert(process_id, kill_tx);
1798
1799 runtime.spawn(async move {
1800 use crate::services::process_hidden::HideWindow;
1801 let mut cmd = TokioCommand::new(&command);
1802 cmd.args(&args);
1803 cmd.stdout(std::process::Stdio::piped());
1804 cmd.stderr(std::process::Stdio::piped());
1805 cmd.hide_window();
1806 if let Some(ref dir) = effective_cwd {
1807 cmd.current_dir(dir);
1808 }
1809 let mut child = match cmd.spawn() {
1810 Ok(c) => c,
1811 Err(e) => {
1812 #[allow(clippy::let_underscore_must_use)]
1813 let _ = sender.send(AsyncMessage::PluginProcessOutput {
1814 process_id,
1815 stdout: String::new(),
1816 stderr: e.to_string(),
1817 exit_code: -1,
1818 });
1819 return;
1820 }
1821 };
1822
1823 let stdout_pipe = child.stdout.take();
1829 let stderr_pipe = child.stderr.take();
1830
1831 let stdout_fut = async {
1832 let mut buf = String::new();
1833 if let Some(s) = stdout_pipe {
1834 #[allow(clippy::let_underscore_must_use)]
1835 let _ = BufReader::new(s).read_to_string(&mut buf).await;
1836 }
1837 buf
1838 };
1839 let stderr_fut = async {
1840 let mut buf = String::new();
1841 if let Some(s) = stderr_pipe {
1842 #[allow(clippy::let_underscore_must_use)]
1843 let _ = BufReader::new(s).read_to_string(&mut buf).await;
1844 }
1845 buf
1846 };
1847 let wait_fut = async {
1848 tokio::select! {
1849 status = child.wait() => {
1850 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
1851 }
1852 _ = &mut kill_rx => {
1853 #[allow(clippy::let_underscore_must_use)]
1857 let _ = child.start_kill();
1858 child
1859 .wait()
1860 .await
1861 .map(|s| s.code().unwrap_or(-1))
1862 .unwrap_or(-1)
1863 }
1864 }
1865 };
1866 let (stdout, stderr, exit_code) = tokio::join!(stdout_fut, stderr_fut, wait_fut);
1867
1868 #[allow(clippy::let_underscore_must_use)]
1869 let _ = sender.send(AsyncMessage::PluginProcessOutput {
1870 process_id,
1871 stdout,
1872 stderr,
1873 exit_code,
1874 });
1875 });
1876 } else {
1877 self.plugin_manager
1878 .read()
1879 .unwrap()
1880 .reject_callback(callback_id, "Async runtime not available".to_string());
1881 }
1882 }
1883
1884 fn handle_spawn_background_process(
1885 &mut self,
1886 process_id: u64,
1887 command: String,
1888 args: Vec<String>,
1889 cwd: Option<String>,
1890 callback_id: JsCallbackId,
1891 ) {
1892 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
1894 use tokio::io::{AsyncBufReadExt, BufReader};
1895 use tokio::process::Command as TokioCommand;
1896
1897 let effective_cwd = cwd.unwrap_or_else(|| {
1898 std::env::current_dir()
1899 .map(|p| p.to_string_lossy().to_string())
1900 .unwrap_or_else(|_| ".".to_string())
1901 });
1902
1903 let sender = bridge.sender();
1904 let sender_stdout = sender.clone();
1905 let sender_stderr = sender.clone();
1906 let callback_id_u64 = callback_id.as_u64();
1907
1908 #[allow(clippy::let_underscore_must_use)]
1910 let handle = runtime.spawn(async move {
1911 use crate::services::process_hidden::HideWindow;
1912 let mut child = match TokioCommand::new(&command)
1913 .args(&args)
1914 .current_dir(&effective_cwd)
1915 .stdout(std::process::Stdio::piped())
1916 .stderr(std::process::Stdio::piped())
1917 .hide_window()
1918 .spawn()
1919 {
1920 Ok(child) => child,
1921 Err(e) => {
1922 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1923 fresh_core::api::PluginAsyncMessage::ProcessExit {
1924 process_id,
1925 callback_id: callback_id_u64,
1926 exit_code: -1,
1927 },
1928 ));
1929 tracing::error!("Failed to spawn background process: {}", e);
1930 return;
1931 }
1932 };
1933
1934 let stdout = child.stdout.take();
1936 let stderr = child.stderr.take();
1937 let pid = process_id;
1938
1939 if let Some(stdout) = stdout {
1941 let sender = sender_stdout;
1942 tokio::spawn(async move {
1943 let reader = BufReader::new(stdout);
1944 let mut lines = reader.lines();
1945 while let Ok(Some(line)) = lines.next_line().await {
1946 let _ =
1947 sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1948 fresh_core::api::PluginAsyncMessage::ProcessStdout {
1949 process_id: pid,
1950 data: line + "\n",
1951 },
1952 ));
1953 }
1954 });
1955 }
1956
1957 if let Some(stderr) = stderr {
1959 let sender = sender_stderr;
1960 tokio::spawn(async move {
1961 let reader = BufReader::new(stderr);
1962 let mut lines = reader.lines();
1963 while let Ok(Some(line)) = lines.next_line().await {
1964 let _ =
1965 sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1966 fresh_core::api::PluginAsyncMessage::ProcessStderr {
1967 process_id: pid,
1968 data: line + "\n",
1969 },
1970 ));
1971 }
1972 });
1973 }
1974
1975 let exit_code = match child.wait().await {
1977 Ok(status) => status.code().unwrap_or(-1),
1978 Err(_) => -1,
1979 };
1980
1981 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
1982 fresh_core::api::PluginAsyncMessage::ProcessExit {
1983 process_id,
1984 callback_id: callback_id_u64,
1985 exit_code,
1986 },
1987 ));
1988 });
1989
1990 self.background_process_handles
1992 .insert(process_id, handle.abort_handle());
1993 } else {
1994 self.plugin_manager
1996 .read()
1997 .unwrap()
1998 .reject_callback(callback_id, "Async runtime not available".to_string());
1999 }
2000 }
2001
2002 fn handle_create_virtual_buffer_with_content(
2003 &mut self,
2004 name: String,
2005 mode: String,
2006 read_only: bool,
2007 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2008 show_line_numbers: bool,
2009 show_cursors: bool,
2010 editing_disabled: bool,
2011 hidden_from_tabs: bool,
2012 request_id: Option<u64>,
2013 ) {
2014 let buffer_id =
2015 self.active_window_mut()
2016 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2017 tracing::info!(
2018 "Created virtual buffer '{}' with mode '{}' (id={:?})",
2019 name,
2020 mode,
2021 buffer_id
2022 );
2023
2024 if let Some(state) = self
2031 .windows
2032 .get_mut(&self.active_window)
2033 .map(|w| &mut w.buffers)
2034 .expect("active window present")
2035 .get_mut(&buffer_id)
2036 {
2037 state.margins.configure_for_line_numbers(show_line_numbers);
2038 state.show_cursors = show_cursors;
2039 state.editing_disabled = editing_disabled;
2040 tracing::debug!(
2041 "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
2042 buffer_id,
2043 show_line_numbers,
2044 show_cursors,
2045 editing_disabled
2046 );
2047 }
2048 let active_split = self
2049 .windows
2050 .get(&self.active_window)
2051 .and_then(|w| w.buffers.splits())
2052 .map(|(mgr, _)| mgr)
2053 .expect("active window must have a populated split layout")
2054 .active_split();
2055 if let Some(view_state) = self
2056 .windows
2057 .get_mut(&self.active_window)
2058 .and_then(|w| w.split_view_states_mut())
2059 .expect("active window must have a populated split layout")
2060 .get_mut(&active_split)
2061 {
2062 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2063 }
2064
2065 if hidden_from_tabs {
2067 if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2068 meta.hidden_from_tabs = true;
2069 }
2070 }
2071
2072 match self.set_virtual_buffer_content(buffer_id, entries) {
2074 Ok(()) => {
2075 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
2076 self.set_active_buffer(buffer_id);
2078 tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
2079
2080 if let Some(req_id) = request_id {
2082 tracing::info!(
2083 "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
2084 req_id,
2085 buffer_id
2086 );
2087 let result = fresh_core::api::VirtualBufferResult {
2089 buffer_id: buffer_id.0 as u64,
2090 split_id: None,
2091 };
2092 self.plugin_manager.read().unwrap().resolve_callback(
2093 fresh_core::api::JsCallbackId::from(req_id),
2094 serde_json::to_string(&result).unwrap_or_default(),
2095 );
2096 tracing::info!(
2097 "CreateVirtualBufferWithContent: resolve_callback sent for request_id={}",
2098 req_id
2099 );
2100 }
2101 }
2102 Err(e) => {
2103 tracing::error!("Failed to set virtual buffer content: {}", e);
2104 }
2105 }
2106 }
2107
2108 fn handle_create_virtual_buffer_in_split(
2109 &mut self,
2110 name: String,
2111 mode: String,
2112 read_only: bool,
2113 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2114 ratio: f32,
2115 direction: Option<String>,
2116 panel_id: Option<String>,
2117 show_line_numbers: bool,
2118 show_cursors: bool,
2119 editing_disabled: bool,
2120 line_wrap: Option<bool>,
2121 before: bool,
2122 role: Option<String>,
2123 request_id: Option<u64>,
2124 ) {
2125 let split_role: Option<crate::view::split::SplitRole> = match role.as_deref() {
2128 Some("utility_dock") => Some(crate::view::split::SplitRole::UtilityDock),
2129 _ => None,
2130 };
2131
2132 if let Some(target_role) = split_role {
2138 if let Some(dock_leaf) = self
2139 .windows
2140 .get(&self.active_window)
2141 .and_then(|w| w.buffers.splits())
2142 .map(|(mgr, _)| mgr)
2143 .expect("active window must have a populated split layout")
2144 .find_leaf_by_role(target_role)
2145 {
2146 let source_split_before_create = self
2151 .windows
2152 .get(&self.active_window)
2153 .and_then(|w| w.buffers.splits())
2154 .map(|(mgr, _)| mgr)
2155 .expect("active window must have a populated split layout")
2156 .active_split();
2157 let buffer_id = self.active_window_mut().create_virtual_buffer(
2158 name.clone(),
2159 mode.clone(),
2160 read_only,
2161 );
2162 if let Some(state) = self
2163 .windows
2164 .get_mut(&self.active_window)
2165 .map(|w| &mut w.buffers)
2166 .expect("active window present")
2167 .get_mut(&buffer_id)
2168 {
2169 state.margins.configure_for_line_numbers(show_line_numbers);
2170 state.show_cursors = show_cursors;
2171 state.editing_disabled = editing_disabled;
2172 }
2173 if let Some(pid) = &panel_id {
2174 self.panel_ids_mut().insert(pid.clone(), buffer_id);
2175 }
2176 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2177 tracing::error!("Failed to set virtual buffer content (dock route): {}", e);
2178 return;
2179 }
2180
2181 self.windows
2185 .get_mut(&self.active_window)
2186 .and_then(|w| w.split_manager_mut())
2187 .expect("active window must have a populated split layout")
2188 .set_active_split(dock_leaf);
2189 self.active_window_mut()
2190 .set_pane_buffer(dock_leaf, buffer_id);
2191
2192 if dock_leaf != source_split_before_create {
2194 if let Some(source_view_state) = self
2195 .windows
2196 .get_mut(&self.active_window)
2197 .and_then(|w| w.split_view_states_mut())
2198 .expect("active window must have a populated split layout")
2199 .get_mut(&source_split_before_create)
2200 {
2201 source_view_state.remove_buffer(buffer_id);
2202 }
2203 }
2204
2205 if let Some(req_id) = request_id {
2206 let result = fresh_core::api::VirtualBufferResult {
2207 buffer_id: buffer_id.0 as u64,
2208 split_id: Some(dock_leaf.0 .0 as u64),
2209 };
2210 self.plugin_manager.read().unwrap().resolve_callback(
2211 fresh_core::api::JsCallbackId::from(req_id),
2212 serde_json::to_string(&result).unwrap_or_default(),
2213 );
2214 }
2215 tracing::info!(
2216 "Routed virtual buffer '{}' into existing utility dock {:?}",
2217 name,
2218 dock_leaf
2219 );
2220 return;
2221 }
2222 }
2225
2226 if let Some(pid) = &panel_id {
2228 if let Some(&existing_buffer_id) = self.panel_ids().get(pid) {
2229 if self
2231 .windows
2232 .get(&self.active_window)
2233 .map(|w| &w.buffers)
2234 .expect("active window present")
2235 .contains_key(&existing_buffer_id)
2236 {
2237 if let Err(e) = self.set_virtual_buffer_content(existing_buffer_id, entries) {
2239 tracing::error!("Failed to update panel content: {}", e);
2240 } else {
2241 tracing::info!("Updated existing panel '{}' content", pid);
2242 }
2243
2244 let splits = self
2246 .windows
2247 .get(&self.active_window)
2248 .and_then(|w| w.buffers.splits())
2249 .map(|(mgr, _)| mgr)
2250 .expect("active window must have a populated split layout")
2251 .splits_for_buffer(existing_buffer_id);
2252 if let Some(&split_id) = splits.first() {
2253 self.windows
2254 .get_mut(&self.active_window)
2255 .and_then(|w| w.split_manager_mut())
2256 .expect("active window must have a populated split layout")
2257 .set_active_split(split_id);
2258 self.active_window_mut()
2261 .set_pane_buffer(split_id, existing_buffer_id);
2262 tracing::debug!("Focused split {:?} containing panel buffer", split_id);
2263 }
2264
2265 if let Some(req_id) = request_id {
2267 let result = fresh_core::api::VirtualBufferResult {
2268 buffer_id: existing_buffer_id.0 as u64,
2269 split_id: splits.first().map(|s| s.0 .0 as u64),
2270 };
2271 self.plugin_manager.read().unwrap().resolve_callback(
2272 fresh_core::api::JsCallbackId::from(req_id),
2273 serde_json::to_string(&result).unwrap_or_default(),
2274 );
2275 }
2276 return;
2277 } else {
2278 tracing::warn!(
2280 "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
2281 pid,
2282 existing_buffer_id
2283 );
2284 self.panel_ids_mut().remove(pid);
2285 }
2287 }
2288 }
2289
2290 let source_split_before_create = self
2296 .windows
2297 .get(&self.active_window)
2298 .and_then(|w| w.buffers.splits())
2299 .map(|(mgr, _)| mgr)
2300 .expect("active window must have a populated split layout")
2301 .active_split();
2302
2303 let buffer_id =
2305 self.active_window_mut()
2306 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2307 tracing::info!(
2308 "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
2309 name,
2310 mode,
2311 buffer_id
2312 );
2313
2314 if let Some(state) = self
2316 .windows
2317 .get_mut(&self.active_window)
2318 .map(|w| &mut w.buffers)
2319 .expect("active window present")
2320 .get_mut(&buffer_id)
2321 {
2322 state.margins.configure_for_line_numbers(show_line_numbers);
2323 state.show_cursors = show_cursors;
2324 state.editing_disabled = editing_disabled;
2325 tracing::debug!(
2326 "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
2327 buffer_id,
2328 show_line_numbers,
2329 show_cursors,
2330 editing_disabled
2331 );
2332 }
2333
2334 if let Some(pid) = panel_id {
2336 self.panel_ids_mut().insert(pid, buffer_id);
2337 }
2338
2339 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2341 tracing::error!("Failed to set virtual buffer content: {}", e);
2342 return;
2343 }
2344
2345 let split_dir = match direction.as_deref() {
2347 Some("vertical") => crate::model::event::SplitDirection::Vertical,
2348 _ => crate::model::event::SplitDirection::Horizontal,
2349 };
2350
2351 let created_split_id =
2357 match if split_role == Some(crate::view::split::SplitRole::UtilityDock) {
2358 self.windows
2359 .get_mut(&self.active_window)
2360 .and_then(|w| w.split_manager_mut())
2361 .expect("active window must have a populated split layout")
2362 .split_root_positioned(split_dir, buffer_id, ratio, before)
2363 } else {
2364 self.windows
2365 .get_mut(&self.active_window)
2366 .and_then(|w| w.split_manager_mut())
2367 .expect("active window must have a populated split layout")
2368 .split_active_positioned(split_dir, buffer_id, ratio, before)
2369 } {
2370 Ok(new_split_id) => {
2371 if new_split_id != source_split_before_create {
2377 if let Some(source_view_state) = self
2378 .windows
2379 .get_mut(&self.active_window)
2380 .and_then(|w| w.split_view_states_mut())
2381 .expect("active window must have a populated split layout")
2382 .get_mut(&source_split_before_create)
2383 {
2384 source_view_state.remove_buffer(buffer_id);
2385 }
2386 }
2387 let mut view_state = SplitViewState::with_buffer(
2389 self.terminal_width,
2390 self.terminal_height,
2391 buffer_id,
2392 );
2393 view_state.apply_config_defaults(
2394 self.config.editor.line_numbers,
2395 self.config.editor.highlight_current_line,
2396 line_wrap.unwrap_or_else(|| {
2397 self.active_window().resolve_line_wrap_for_buffer(buffer_id)
2398 }),
2399 self.config.editor.wrap_indent,
2400 self.active_window()
2401 .resolve_wrap_column_for_buffer(buffer_id),
2402 self.config.editor.rulers.clone(),
2403 );
2404 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2406 self.windows
2407 .get_mut(&self.active_window)
2408 .and_then(|w| w.split_view_states_mut())
2409 .expect("active window must have a populated split layout")
2410 .insert(new_split_id, view_state);
2411
2412 self.windows
2414 .get_mut(&self.active_window)
2415 .and_then(|w| w.split_manager_mut())
2416 .expect("active window must have a populated split layout")
2417 .set_active_split(new_split_id);
2418 if let Some(target_role) = split_role {
2426 self.windows
2427 .get_mut(&self.active_window)
2428 .and_then(|w| w.split_manager_mut())
2429 .expect("active window must have a populated split layout")
2430 .clear_role(target_role);
2431 self.windows
2432 .get_mut(&self.active_window)
2433 .and_then(|w| w.split_manager_mut())
2434 .expect("active window must have a populated split layout")
2435 .set_leaf_role(new_split_id, Some(target_role));
2436 tracing::info!(
2437 "Tagged new dock leaf {:?} with role {:?}",
2438 new_split_id,
2439 target_role
2440 );
2441 }
2442
2443 tracing::info!(
2444 "Created {:?} split with virtual buffer {:?}",
2445 split_dir,
2446 buffer_id
2447 );
2448 Some(new_split_id)
2449 }
2450 Err(e) => {
2451 tracing::error!("Failed to create split: {}", e);
2452 self.set_active_buffer(buffer_id);
2454 None
2455 }
2456 };
2457
2458 if let Some(req_id) = request_id {
2461 tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
2462 let result = fresh_core::api::VirtualBufferResult {
2463 buffer_id: buffer_id.0 as u64,
2464 split_id: created_split_id.map(|s| s.0 .0 as u64),
2465 };
2466 self.plugin_manager.read().unwrap().resolve_callback(
2467 fresh_core::api::JsCallbackId::from(req_id),
2468 serde_json::to_string(&result).unwrap_or_default(),
2469 );
2470 }
2471 }
2472
2473 fn handle_create_virtual_buffer_in_existing_split(
2474 &mut self,
2475 name: String,
2476 mode: String,
2477 read_only: bool,
2478 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2479 split_id: SplitId,
2480 show_line_numbers: bool,
2481 show_cursors: bool,
2482 editing_disabled: bool,
2483 line_wrap: Option<bool>,
2484 request_id: Option<u64>,
2485 ) {
2486 let buffer_id =
2488 self.active_window_mut()
2489 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2490 tracing::info!(
2491 "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
2492 name,
2493 mode,
2494 split_id,
2495 buffer_id
2496 );
2497
2498 if let Some(state) = self
2500 .windows
2501 .get_mut(&self.active_window)
2502 .map(|w| &mut w.buffers)
2503 .expect("active window present")
2504 .get_mut(&buffer_id)
2505 {
2506 state.margins.configure_for_line_numbers(show_line_numbers);
2507 state.show_cursors = show_cursors;
2508 state.editing_disabled = editing_disabled;
2509 }
2510
2511 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2513 tracing::error!("Failed to set virtual buffer content: {}", e);
2514 return;
2515 }
2516
2517 let leaf_id = LeafId(split_id);
2520 self.windows
2521 .get_mut(&self.active_window)
2522 .and_then(|w| w.split_manager_mut())
2523 .expect("active window must have a populated split layout")
2524 .set_active_split(leaf_id);
2525 self.active_window_mut().set_pane_buffer(leaf_id, buffer_id);
2526
2527 if let Some(view_state) = self
2533 .windows
2534 .get_mut(&self.active_window)
2535 .and_then(|w| w.split_view_states_mut())
2536 .expect("active window must have a populated split layout")
2537 .get_mut(&leaf_id)
2538 {
2539 view_state.switch_buffer(buffer_id);
2540 view_state.add_buffer(buffer_id);
2541 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2542
2543 if let Some(wrap) = line_wrap {
2545 view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
2546 }
2547 }
2548
2549 tracing::info!(
2550 "Displayed virtual buffer {:?} in split {:?}",
2551 buffer_id,
2552 split_id
2553 );
2554
2555 if let Some(req_id) = request_id {
2557 let result = fresh_core::api::VirtualBufferResult {
2558 buffer_id: buffer_id.0 as u64,
2559 split_id: Some(split_id.0 as u64),
2560 };
2561 self.plugin_manager.read().unwrap().resolve_callback(
2562 fresh_core::api::JsCallbackId::from(req_id),
2563 serde_json::to_string(&result).unwrap_or_default(),
2564 );
2565 }
2566 }
2567
2568 fn handle_show_action_popup(
2569 &mut self,
2570 popup_id: String,
2571 title: String,
2572 message: String,
2573 actions: Vec<fresh_core::api::ActionPopupAction>,
2574 ) {
2575 tracing::info!(
2576 "Action popup requested: id={}, title={}, actions={}",
2577 popup_id,
2578 title,
2579 actions.len()
2580 );
2581
2582 let items: Vec<crate::model::event::PopupListItemData> = actions
2584 .iter()
2585 .map(|action| crate::model::event::PopupListItemData {
2586 text: action.label.clone(),
2587 detail: None,
2588 icon: None,
2589 data: Some(action.id.clone()),
2590 })
2591 .collect();
2592
2593 drop(actions);
2598
2599 let popup_data = crate::model::event::PopupData {
2601 kind: crate::model::event::PopupKindHint::List,
2602 title: Some(title),
2603 description: Some(message),
2604 transient: false,
2605 content: crate::model::event::PopupContentData::List { items, selected: 0 },
2606 position: crate::model::event::PopupPositionData::BottomRight,
2607 width: 60,
2608 max_height: 15,
2609 bordered: true,
2610 };
2611
2612 let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
2622 popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
2623 popup_id: popup_id.clone(),
2624 };
2625
2626 {
2633 let theme = self.theme();
2634 popup_obj.background_style = ratatui::style::Style::default().bg(theme.popup_bg);
2635 popup_obj.border_style = ratatui::style::Style::default().fg(theme.popup_border_fg);
2636 }
2637
2638 while self
2650 .active_state()
2651 .popups
2652 .top()
2653 .is_some_and(|p| matches!(p.resolver, crate::view::popup::PopupResolver::LspStatus))
2654 {
2655 self.active_state_mut().popups.hide();
2656 }
2657
2658 let existing_idx = self.global_popups.all().iter().position(|p| {
2665 matches!(
2666 &p.resolver,
2667 crate::view::popup::PopupResolver::PluginAction { popup_id: id } if id == &popup_id,
2668 )
2669 });
2670 if let Some(idx) = existing_idx {
2671 if let Some(slot) = self.global_popups.get_mut(idx) {
2672 *slot = popup_obj;
2673 }
2674 } else {
2675 self.global_popups.show(popup_obj);
2676 }
2677 tracing::info!(
2678 "Action popup shown: id={}, stack_depth={}",
2679 popup_id,
2680 self.global_popups.all().len()
2681 );
2682 }
2683
2684 fn handle_set_lsp_menu_contributions(
2694 &mut self,
2695 plugin_id: String,
2696 language: String,
2697 items: Vec<fresh_core::api::LspMenuItem>,
2698 ) {
2699 let key = (language.clone(), plugin_id.clone());
2700 if items.is_empty() {
2701 self.active_window_mut().lsp_menu_contributions.remove(&key);
2702 } else {
2703 self.active_window_mut()
2704 .lsp_menu_contributions
2705 .insert(key, items);
2706 }
2707 self.refresh_lsp_status_popup_if_open();
2712 }
2713
2714 fn handle_create_terminal(
2715 &mut self,
2716 cwd: Option<String>,
2717 direction: Option<String>,
2718 ratio: Option<f32>,
2719 focus: Option<bool>,
2720 persistent: bool,
2721 target_session_id: Option<fresh_core::WindowId>,
2722 request_id: u64,
2723 ) {
2724 let route_to_inactive = match target_session_id {
2731 Some(id) if id != self.active_window && self.windows.contains_key(&id) => Some(id),
2732 _ => None,
2733 };
2734 if let Some(target) = route_to_inactive {
2735 self.handle_create_terminal_in_inactive_session(target, cwd, persistent, request_id);
2736 return;
2737 }
2738 let (cols, rows) = self.get_terminal_dimensions();
2739
2740 let __window_bridge = self.active_window().bridge.clone();
2744 self.active_window_mut()
2745 .terminal_manager
2746 .set_async_bridge(__window_bridge);
2747
2748 let working_dir = cwd
2750 .map(std::path::PathBuf::from)
2751 .unwrap_or_else(|| self.working_dir.clone());
2752
2753 let terminal_root = self.dir_context.terminal_dir_for(&working_dir);
2755 if let Err(e) = self.authority.filesystem.create_dir_all(&terminal_root) {
2756 tracing::warn!("Failed to create terminal directory: {}", e);
2757 }
2758 let predicted_terminal_id = self.active_window().terminal_manager.next_terminal_id();
2759 let name_stem = if persistent {
2766 format!("fresh-terminal-{}", predicted_terminal_id.0)
2767 } else {
2768 let nanos = std::time::SystemTime::now()
2769 .duration_since(std::time::UNIX_EPOCH)
2770 .map(|d| d.as_nanos())
2771 .unwrap_or(0);
2772 format!("fresh-terminal-eph-{}-{}", predicted_terminal_id.0, nanos)
2773 };
2774 let log_path = terminal_root.join(format!("{}.log", name_stem));
2775 let backing_path = terminal_root.join(format!("{}.txt", name_stem));
2776 self.active_window_mut()
2777 .terminal_backing_files
2778 .insert(predicted_terminal_id, backing_path);
2779 let backing_path_for_spawn = self
2780 .windows
2781 .get(&self.active_window)
2782 .map(|w| &w.terminal_backing_files)
2783 .expect("active window present")
2784 .get(&predicted_terminal_id)
2785 .cloned();
2786 let wrapper_for_spawn = self.resolved_terminal_wrapper();
2787
2788 match self
2789 .windows
2790 .get_mut(&self.active_window)
2791 .map(|w| &mut w.terminal_manager)
2792 .expect("active window present")
2793 .spawn(
2794 cols,
2795 rows,
2796 Some(working_dir),
2797 Some(log_path.clone()),
2798 backing_path_for_spawn,
2799 wrapper_for_spawn,
2800 ) {
2801 Ok(terminal_id) => {
2802 self.active_window_mut()
2804 .terminal_log_files
2805 .insert(terminal_id, log_path.clone());
2806 let leader_pid = self
2811 .active_window()
2812 .terminal_manager
2813 .get(terminal_id)
2814 .and_then(|h| h.pid());
2815 if let Some(pid) = leader_pid {
2816 let label = format!("terminal #{}", terminal_id.0);
2817 self.active_window_mut().process_groups.register(pid, label);
2818 }
2819 if terminal_id != predicted_terminal_id {
2827 let existing = self
2828 .active_window_mut()
2829 .terminal_backing_files
2830 .remove(&predicted_terminal_id);
2831 let fixed_backing = if persistent {
2832 terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
2833 } else {
2834 existing.unwrap_or_else(|| terminal_root.join(format!("{}.txt", name_stem)))
2835 };
2836 self.active_window_mut()
2837 .terminal_backing_files
2838 .insert(terminal_id, fixed_backing);
2839 }
2840 if !persistent {
2841 self.active_window_mut()
2842 .ephemeral_terminals
2843 .insert(terminal_id);
2844 }
2845
2846 let active_split = self
2860 .windows
2861 .get(&self.active_window)
2862 .and_then(|w| w.buffers.splits())
2863 .map(|(mgr, _)| mgr)
2864 .expect("active window must have a populated split layout")
2865 .active_split();
2866 let buffer_id = if direction.is_some() {
2867 self.create_terminal_buffer_detached(terminal_id)
2868 } else {
2869 self.create_terminal_buffer_attached(terminal_id, active_split)
2870 };
2871
2872 let created_split_id = if let Some(dir_str) = direction.as_deref() {
2873 let split_dir = match dir_str {
2874 "horizontal" => crate::model::event::SplitDirection::Horizontal,
2875 _ => crate::model::event::SplitDirection::Vertical,
2876 };
2877
2878 let split_ratio = ratio.unwrap_or(0.5);
2879 match self
2880 .split_manager_mut()
2881 .split_active(split_dir, buffer_id, split_ratio)
2882 {
2883 Ok(new_split_id) => {
2884 let mut view_state = SplitViewState::with_buffer(
2885 self.terminal_width,
2886 self.terminal_height,
2887 buffer_id,
2888 );
2889 view_state.apply_config_defaults(
2890 self.config.editor.line_numbers,
2891 self.config.editor.highlight_current_line,
2892 false,
2893 false,
2894 None,
2895 self.config.editor.rulers.clone(),
2896 );
2897 view_state.viewport.line_wrap_enabled = false;
2901 self.windows
2902 .get_mut(&self.active_window)
2903 .and_then(|w| w.split_view_states_mut())
2904 .expect("active window must have a populated split layout")
2905 .insert(new_split_id, view_state);
2906
2907 if focus.unwrap_or(true) {
2908 self.windows
2909 .get_mut(&self.active_window)
2910 .and_then(|w| w.split_manager_mut())
2911 .expect("active window must have a populated split layout")
2912 .set_active_split(new_split_id);
2913 }
2914
2915 tracing::info!(
2916 "Created {:?} split for terminal {:?} with buffer {:?}",
2917 split_dir,
2918 terminal_id,
2919 buffer_id
2920 );
2921 Some(new_split_id)
2922 }
2923 Err(e) => {
2924 tracing::error!(
2925 "Failed to create split for terminal: {}; \
2926 falling back to active split",
2927 e
2928 );
2929 if let Some(view_state) = self
2934 .windows
2935 .get_mut(&self.active_window)
2936 .and_then(|w| w.split_view_states_mut())
2937 .expect("active window must have a populated split layout")
2938 .get_mut(&active_split)
2939 {
2940 view_state.add_buffer(buffer_id);
2941 view_state.viewport.line_wrap_enabled = false;
2942 }
2943 self.set_active_buffer(buffer_id);
2944 None
2945 }
2946 }
2947 } else {
2948 self.set_active_buffer(buffer_id);
2950 None
2951 };
2952
2953 self.active_window_mut().resize_visible_terminals();
2955
2956 let result = fresh_core::api::TerminalResult {
2958 buffer_id: buffer_id.0 as u64,
2959 terminal_id: terminal_id.0 as u64,
2960 split_id: created_split_id.map(|s| s.0 .0 as u64),
2961 };
2962 self.plugin_manager.read().unwrap().resolve_callback(
2963 fresh_core::api::JsCallbackId::from(request_id),
2964 serde_json::to_string(&result).unwrap_or_default(),
2965 );
2966
2967 tracing::info!(
2968 "Plugin created terminal {:?} with buffer {:?}",
2969 terminal_id,
2970 buffer_id
2971 );
2972 }
2973 Err(e) => {
2974 tracing::error!("Failed to create terminal for plugin: {}", e);
2975 self.plugin_manager.read().unwrap().reject_callback(
2976 fresh_core::api::JsCallbackId::from(request_id),
2977 format!("Failed to create terminal: {}", e),
2978 );
2979 }
2980 }
2981 }
2982 fn handle_create_terminal_in_inactive_session(
2994 &mut self,
2995 target: fresh_core::WindowId,
2996 cwd: Option<String>,
2997 persistent: bool,
2998 request_id: u64,
2999 ) {
3000 let (cols, rows) = self.get_terminal_dimensions();
3001 let __bridge_clone = self.async_bridge.clone();
3002 if let Some(bridge) = __bridge_clone {
3003 self.active_window_mut()
3004 .terminal_manager
3005 .set_async_bridge(bridge);
3006 }
3007
3008 let working_dir = cwd.map(std::path::PathBuf::from).unwrap_or_else(|| {
3012 self.windows
3013 .get(&target)
3014 .map(|s| s.root.clone())
3015 .unwrap_or_else(|| self.working_dir.clone())
3016 });
3017
3018 let terminal_root = self.dir_context.terminal_dir_for(&working_dir);
3019 if let Err(e) = self.authority.filesystem.create_dir_all(&terminal_root) {
3020 tracing::warn!("Failed to create terminal directory: {}", e);
3021 }
3022 let predicted_terminal_id = self.active_window().terminal_manager.next_terminal_id();
3023 let name_stem = if persistent {
3024 format!("fresh-terminal-{}", predicted_terminal_id.0)
3025 } else {
3026 let nanos = std::time::SystemTime::now()
3027 .duration_since(std::time::UNIX_EPOCH)
3028 .map(|d| d.as_nanos())
3029 .unwrap_or(0);
3030 format!("fresh-terminal-eph-{}-{}", predicted_terminal_id.0, nanos)
3031 };
3032 let log_path = terminal_root.join(format!("{}.log", name_stem));
3033 let backing_path = terminal_root.join(format!("{}.txt", name_stem));
3034 self.active_window_mut()
3035 .terminal_backing_files
3036 .insert(predicted_terminal_id, backing_path);
3037 let backing_path_for_spawn = self
3038 .active_window()
3039 .terminal_backing_files
3040 .get(&predicted_terminal_id)
3041 .cloned();
3042
3043 let wrapper_for_spawn = self.resolved_terminal_wrapper();
3044 let terminal_id = match self
3045 .windows
3046 .get_mut(&self.active_window)
3047 .map(|w| &mut w.terminal_manager)
3048 .expect("active window present")
3049 .spawn(
3050 cols,
3051 rows,
3052 Some(working_dir),
3053 Some(log_path.clone()),
3054 backing_path_for_spawn,
3055 wrapper_for_spawn,
3056 ) {
3057 Ok(id) => id,
3058 Err(e) => {
3059 tracing::error!("Failed to create terminal for inactive session: {}", e);
3060 self.plugin_manager.read().unwrap().reject_callback(
3061 fresh_core::api::JsCallbackId::from(request_id),
3062 format!("Failed to create terminal: {}", e),
3063 );
3064 return;
3065 }
3066 };
3067 self.active_window_mut()
3068 .terminal_log_files
3069 .insert(terminal_id, log_path.clone());
3070 if terminal_id != predicted_terminal_id {
3071 self.active_window_mut()
3072 .terminal_backing_files
3073 .remove(&predicted_terminal_id);
3074 let backing_path = terminal_root.join(format!("fresh-terminal-{}.txt", terminal_id.0));
3075 self.active_window_mut()
3076 .terminal_backing_files
3077 .insert(terminal_id, backing_path);
3078 }
3079 if !persistent {
3080 self.active_window_mut()
3081 .ephemeral_terminals
3082 .insert(terminal_id);
3083 }
3084
3085 let buffer_id = self.create_terminal_buffer_detached(terminal_id);
3089 if let Some(state) = self.detach_buffer_from_all_windows(buffer_id) {
3090 if let Some(s) = self.windows.get_mut(&target) {
3091 s.buffers.insert(buffer_id, state);
3092 }
3093 }
3094 let leader_pid = self
3101 .active_window()
3102 .terminal_manager
3103 .get(terminal_id)
3104 .and_then(|h| h.pid());
3105 if let Some(pid) = leader_pid {
3106 let label = format!("terminal #{}", terminal_id.0);
3107 if let Some(target_window) = self.windows.get_mut(&target) {
3108 target_window.process_groups.register(pid, label);
3109 }
3110 }
3111
3112 let target_session = self.windows.get_mut(&target);
3117 let new_split_id = if let Some(session) = target_session {
3118 if let Some((mgr, view_states)) = session.buffers.splits_mut() {
3119 let split_dir = crate::model::event::SplitDirection::Horizontal;
3120 match mgr.split_active(split_dir, buffer_id, 0.5) {
3121 Ok(new_split_id) => {
3122 let mut view_state = SplitViewState::with_buffer(
3123 self.terminal_width,
3124 self.terminal_height,
3125 buffer_id,
3126 );
3127 view_state.viewport.line_wrap_enabled = false;
3128 view_states.insert(new_split_id, view_state);
3129 Some(new_split_id)
3130 }
3131 Err(e) => {
3132 tracing::warn!(
3133 "Failed to split target session's tree for terminal: {}; \
3134 buffer is attached to the session but not visible in any leaf",
3135 e
3136 );
3137 None
3138 }
3139 }
3140 } else {
3141 let manager = crate::view::split::SplitManager::new(buffer_id);
3145 let active_leaf = manager.active_split();
3146 let mut view_states = std::collections::HashMap::new();
3147 let mut vs = SplitViewState::with_buffer(
3148 self.terminal_width,
3149 self.terminal_height,
3150 buffer_id,
3151 );
3152 vs.viewport.line_wrap_enabled = false;
3153 view_states.insert(active_leaf, vs);
3154 session.buffers.set_splits((manager, view_states));
3155 Some(active_leaf.into())
3156 }
3157 } else {
3158 None
3159 };
3160
3161 let result = fresh_core::api::TerminalResult {
3162 buffer_id: buffer_id.0 as u64,
3163 terminal_id: terminal_id.0 as u64,
3164 split_id: new_split_id.map(|s| s.0 .0 as u64),
3165 };
3166 self.plugin_manager.read().unwrap().resolve_callback(
3167 fresh_core::api::JsCallbackId::from(request_id),
3168 serde_json::to_string(&result).unwrap(),
3169 );
3170 }
3171
3172 fn handle_get_split_by_label(&mut self, label: String, request_id: u64) {
3175 let split_id = self
3176 .windows
3177 .get(&self.active_window)
3178 .and_then(|w| w.buffers.splits())
3179 .map(|(mgr, _)| mgr)
3180 .expect("active window must have a populated split layout")
3181 .find_split_by_label(&label);
3182 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
3183 let json =
3184 serde_json::to_string(&split_id.map(|s| s.0 .0)).unwrap_or_else(|_| "null".to_string());
3185 self.plugin_manager
3186 .read()
3187 .unwrap()
3188 .resolve_callback(callback_id, json);
3189 }
3190
3191 fn handle_set_buffer_show_cursors(&mut self, buffer_id: BufferId, show: bool) {
3192 if let Some(state) = self
3193 .windows
3194 .get_mut(&self.active_window)
3195 .map(|w| &mut w.buffers)
3196 .expect("active window present")
3197 .get_mut(&buffer_id)
3198 {
3199 state.show_cursors = show;
3200 } else {
3201 tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
3202 }
3203 }
3204
3205 fn handle_override_theme_colors(
3206 &mut self,
3207 overrides: std::collections::HashMap<String, [u8; 3]>,
3208 ) {
3209 let pairs = overrides
3210 .into_iter()
3211 .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
3212 let applied = self.theme.write().unwrap().override_colors(pairs);
3213 if applied > 0 {
3214 self.reapply_all_overlays();
3217 }
3218 }
3219
3220 fn handle_await_next_key(&mut self, callback_id: fresh_core::api::JsCallbackId) {
3221 if let Some(payload) = self
3225 .active_window_mut()
3226 .pending_key_capture_buffer
3227 .pop_front()
3228 {
3229 let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
3230 self.plugin_manager
3231 .read()
3232 .unwrap()
3233 .resolve_callback(callback_id, json);
3234 } else {
3235 self.active_window_mut()
3236 .pending_next_key_callbacks
3237 .push_back(callback_id);
3238 }
3239 }
3240
3241 fn handle_spawn_process(
3242 &mut self,
3243 command: String,
3244 args: Vec<String>,
3245 cwd: Option<String>,
3246 callback_id: fresh_core::api::JsCallbackId,
3247 ) {
3248 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3249 let effective_cwd = cwd.or_else(|| {
3250 std::env::current_dir()
3251 .map(|p| p.to_string_lossy().to_string())
3252 .ok()
3253 });
3254 let sender = bridge.sender();
3255 let spawner = self.authority.process_spawner.clone();
3256 runtime.spawn(async move {
3257 #[allow(clippy::let_underscore_must_use)]
3258 match spawner.spawn(command, args, effective_cwd).await {
3259 Ok(result) => {
3260 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3261 process_id: callback_id.as_u64(),
3262 stdout: result.stdout,
3263 stderr: result.stderr,
3264 exit_code: result.exit_code,
3265 });
3266 }
3267 Err(e) => {
3268 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3269 process_id: callback_id.as_u64(),
3270 stdout: String::new(),
3271 stderr: e.to_string(),
3272 exit_code: -1,
3273 });
3274 }
3275 }
3276 });
3277 } else {
3278 self.plugin_manager
3279 .read()
3280 .unwrap()
3281 .reject_callback(callback_id, "Async runtime not available".to_string());
3282 }
3283 }
3284
3285 fn handle_kill_host_process(&mut self, process_id: u64) {
3286 if let Some(tx) = self.host_process_handles.remove(&process_id) {
3290 #[allow(clippy::let_underscore_must_use)]
3291 let _ = tx.send(());
3292 tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
3293 } else {
3294 tracing::debug!(
3295 "KillHostProcess: unknown process_id={} (already exited?)",
3296 process_id
3297 );
3298 }
3299 }
3300
3301 fn handle_set_authority(&mut self, payload: serde_json::Value) {
3302 match serde_json::from_value::<crate::services::authority::AuthorityPayload>(payload) {
3305 Ok(parsed) => {
3306 match crate::services::authority::Authority::from_plugin_payload(parsed) {
3307 Ok(auth) => {
3308 tracing::info!("Plugin installed new authority");
3309 self.install_authority(auth);
3310 }
3311 Err(e) => {
3312 tracing::warn!("setAuthority: invalid payload: {}", e);
3313 self.set_status_message(format!("setAuthority rejected: {}", e));
3314 }
3315 }
3316 }
3317 Err(e) => {
3318 tracing::warn!("setAuthority: failed to parse payload: {}", e);
3319 self.set_status_message(format!("setAuthority rejected: {}", e));
3320 }
3321 }
3322 }
3323
3324 fn handle_set_remote_indicator_state(&mut self, state: serde_json::Value) {
3325 match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(state)
3328 {
3329 Ok(over) => {
3330 self.remote_indicator_override = Some(over);
3331 }
3332 Err(e) => {
3333 tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
3334 self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
3335 }
3336 }
3337 }
3338
3339 fn handle_spawn_process_wait(
3340 &mut self,
3341 process_id: u64,
3342 callback_id: fresh_core::api::JsCallbackId,
3343 ) {
3344 tracing::warn!(
3345 "SpawnProcessWait not fully implemented - process_id={}",
3346 process_id
3347 );
3348 self.plugin_manager.read().unwrap().reject_callback(
3349 callback_id,
3350 format!(
3351 "SpawnProcessWait not yet fully implemented for process_id={}",
3352 process_id
3353 ),
3354 );
3355 }
3356
3357 fn handle_delay(&mut self, callback_id: fresh_core::api::JsCallbackId, duration_ms: u64) {
3358 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3359 let sender = bridge.sender();
3360 let callback_id_u64 = callback_id.as_u64();
3361 runtime.spawn(async move {
3362 tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
3363 #[allow(clippy::let_underscore_must_use)]
3364 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
3365 fresh_core::api::PluginAsyncMessage::DelayComplete {
3366 callback_id: callback_id_u64,
3367 },
3368 ));
3369 });
3370 } else {
3371 std::thread::sleep(std::time::Duration::from_millis(duration_ms));
3372 self.plugin_manager
3373 .read()
3374 .unwrap()
3375 .resolve_callback(callback_id, "null".to_string());
3376 }
3377 }
3378
3379 fn handle_kill_background_process(&mut self, process_id: u64) {
3380 if let Some(handle) = self.background_process_handles.remove(&process_id) {
3381 handle.abort();
3382 tracing::debug!("Killed background process {}", process_id);
3383 }
3384 }
3385
3386 fn handle_create_virtual_buffer(&mut self, name: String, mode: String, read_only: bool) {
3387 let buffer_id =
3388 self.active_window_mut()
3389 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3390 tracing::info!(
3391 "Created virtual buffer '{}' with mode '{}' (id={:?})",
3392 name,
3393 mode,
3394 buffer_id
3395 );
3396 }
3398
3399 fn handle_set_virtual_buffer_content(
3400 &mut self,
3401 buffer_id: BufferId,
3402 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3403 ) {
3404 match self.set_virtual_buffer_content(buffer_id, entries) {
3405 Ok(()) => {
3406 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
3407 }
3408 Err(e) => {
3409 tracing::error!("Failed to set virtual buffer content: {}", e);
3410 }
3411 }
3412 }
3413
3414 fn handle_mount_widget_panel(
3415 &mut self,
3416 panel_id: u64,
3417 buffer_id: BufferId,
3418 spec: fresh_core::api::WidgetSpec,
3419 ) {
3420 let prev = std::collections::HashMap::new();
3425 let prev_focus = String::new();
3426 let panel_width = self.widget_panel_width(buffer_id);
3427 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
3428 let focus_cursor = out.focus_cursor;
3429 self.widget_registry.mount(
3430 panel_id,
3431 buffer_id,
3432 spec,
3433 out.hits,
3434 out.instance_states,
3435 out.focus_key,
3436 out.tabbable,
3437 );
3438 let entries = out.entries;
3439 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3440 tracing::error!(
3441 "Failed to render mounted widget panel {} into {:?}: {}",
3442 panel_id,
3443 buffer_id,
3444 e
3445 );
3446 } else {
3447 tracing::debug!(
3448 "Mounted widget panel {} into buffer {:?}",
3449 panel_id,
3450 buffer_id
3451 );
3452 }
3453 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3454 }
3455
3456 fn handle_update_widget_panel(&mut self, panel_id: u64, spec: fresh_core::api::WidgetSpec) {
3457 let prev = match self.widget_registry.instance_states(panel_id) {
3458 Some(s) => s.clone(),
3459 None => {
3460 tracing::debug!(
3461 "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
3462 panel_id
3463 );
3464 return;
3465 }
3466 };
3467 let prev_focus = self
3468 .widget_registry
3469 .focus_key(panel_id)
3470 .map(|s| s.to_string())
3471 .unwrap_or_default();
3472 let buffer_id_for_width = self
3473 .widget_registry
3474 .buffer_and_spec(panel_id)
3475 .map(|(b, _)| b)
3476 .unwrap_or(BufferId(0));
3477 let panel_width = self.widget_panel_width(buffer_id_for_width);
3478 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
3479 let focus_cursor = out.focus_cursor;
3480 let entries = out.entries;
3481 match self.widget_registry.update(
3482 panel_id,
3483 spec,
3484 out.hits,
3485 out.instance_states,
3486 out.focus_key,
3487 out.tabbable,
3488 ) {
3489 Ok(buffer_id) => {
3490 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3491 tracing::error!("Failed to render updated widget panel {}: {}", panel_id, e);
3492 }
3493 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3494 }
3495 Err(()) => {
3496 tracing::debug!(
3497 "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
3498 panel_id
3499 );
3500 }
3501 }
3502 }
3503
3504 fn apply_widget_focus_cursor(
3515 &mut self,
3516 buffer_id: BufferId,
3517 entries: &[fresh_core::text_property::TextPropertyEntry],
3518 focus_cursor: Option<crate::widgets::FocusCursor>,
3519 ) {
3520 let absolute_byte = focus_cursor.map(|fc| {
3521 let row = fc.buffer_row as usize;
3522 let prefix: usize = entries.iter().take(row).map(|e| e.text.len()).sum();
3523 prefix + fc.byte_in_row as usize
3524 });
3525
3526 if let Some(state) = self
3527 .windows
3528 .get_mut(&self.active_window)
3529 .map(|w| &mut w.buffers)
3530 .expect("active window present")
3531 .get_mut(&buffer_id)
3532 {
3533 state.show_cursors = absolute_byte.is_some();
3534 }
3535
3536 if let Some(byte) = absolute_byte {
3537 for vs in self
3538 .windows
3539 .get_mut(&self.active_window)
3540 .and_then(|w| w.split_view_states_mut())
3541 .expect("active window must have a populated split layout")
3542 .values_mut()
3543 {
3544 if vs.buffer_state(buffer_id).is_some() {
3545 let cursor = vs.cursors.primary_mut();
3546 cursor.position = byte;
3547 }
3548 }
3549 }
3550 }
3551
3552 fn widget_panel_width(&self, buffer_id: BufferId) -> u32 {
3561 let raw = self
3562 .windows
3563 .get(&self.active_window)
3564 .and_then(|w| w.buffers.splits())
3565 .map(|(_, vs)| vs)
3566 .expect("active window must have a populated split layout")
3567 .values()
3568 .find(|vs| vs.buffer_state(buffer_id).is_some() && vs.viewport.width > 0)
3569 .map(|vs| vs.viewport.width as u32)
3570 .unwrap_or_else(|| self.terminal_width.max(1) as u32);
3571 raw.saturating_sub(2).max(10)
3574 }
3575
3576 pub(super) fn rerender_widget_panel(&mut self, panel_id: u64) {
3582 let (buffer_id, spec) = match self.widget_registry.buffer_and_spec(panel_id) {
3583 Some(s) => s,
3584 None => return,
3585 };
3586 let prev = self
3587 .widget_registry
3588 .instance_states(panel_id)
3589 .cloned()
3590 .unwrap_or_default();
3591 let prev_focus = self
3592 .widget_registry
3593 .focus_key(panel_id)
3594 .map(|s| s.to_string())
3595 .unwrap_or_default();
3596 let is_floating = buffer_id == FLOATING_PANEL_BUFFER_ID;
3597 let panel_width = if is_floating {
3598 self.floating_panel_inner_width()
3599 } else {
3600 self.widget_panel_width(buffer_id)
3601 };
3602 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
3603 let focus_cursor = out.focus_cursor;
3604 let entries = out.entries;
3605 let embeds = out.embeds;
3606 if self
3607 .widget_registry
3608 .update(
3609 panel_id,
3610 spec,
3611 out.hits,
3612 out.instance_states,
3613 out.focus_key,
3614 out.tabbable,
3615 )
3616 .is_err()
3617 {
3618 tracing::warn!("rerender_widget_panel({}) lost panel mid-call", panel_id);
3619 return;
3620 }
3621 if is_floating {
3622 if let Some(fwp) = self.floating_widget_panel.as_mut() {
3623 if fwp.panel_id == panel_id {
3624 fwp.entries = entries;
3625 fwp.focus_cursor = focus_cursor;
3626 fwp.embeds = embeds;
3627 }
3628 }
3629 return;
3630 }
3631 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3632 tracing::error!("rerender_widget_panel({}) failed: {}", panel_id, e);
3633 }
3634 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3635 }
3636
3637 fn handle_widget_mutate(&mut self, panel_id: u64, mutation: fresh_core::api::WidgetMutation) {
3643 use fresh_core::api::WidgetMutation;
3644
3645 if self.widget_registry.get(panel_id).is_none() {
3647 tracing::debug!(
3648 "WidgetMutate for unknown panel {} ignored (not mounted)",
3649 panel_id
3650 );
3651 return;
3652 }
3653
3654 match mutation {
3655 WidgetMutation::SetValue {
3656 widget_key,
3657 value,
3658 cursor_byte,
3659 } => {
3660 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3666 let cb = match cursor_byte {
3667 Some(c) if c >= 0 => (c as u32).min(value.len() as u32),
3668 _ => value.len() as u32,
3669 };
3670 let scroll = match panel.instance_states.get(&widget_key) {
3671 Some(crate::widgets::WidgetInstanceState::Text { scroll, .. }) => *scroll,
3672 _ => 0,
3673 };
3674 panel.instance_states.insert(
3675 widget_key,
3676 crate::widgets::WidgetInstanceState::Text {
3677 value,
3678 cursor_byte: cb,
3679 scroll,
3680 },
3681 );
3682 }
3683 }
3684 WidgetMutation::SetChecked {
3685 widget_key,
3686 checked,
3687 } => {
3688 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3692 crate::widgets::set_toggle_checked_in_spec(
3693 &mut panel.spec,
3694 &widget_key,
3695 checked,
3696 );
3697 }
3698 }
3699 WidgetMutation::SetSelectedIndex { widget_key, index } => {
3700 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3702 let prev_scroll = match panel.instance_states.get(&widget_key) {
3703 Some(crate::widgets::WidgetInstanceState::List {
3704 scroll_offset, ..
3705 }) => *scroll_offset,
3706 _ => 0,
3707 };
3708 panel.instance_states.insert(
3709 widget_key,
3710 crate::widgets::WidgetInstanceState::List {
3711 scroll_offset: prev_scroll,
3712 selected_index: index,
3713 },
3714 );
3715 }
3716 }
3717 WidgetMutation::SetItems {
3718 widget_key,
3719 items,
3720 item_keys,
3721 } => {
3722 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3724 crate::widgets::set_list_items_in_spec(
3725 &mut panel.spec,
3726 &widget_key,
3727 items,
3728 item_keys,
3729 );
3730 }
3731 }
3732 WidgetMutation::SetExpandedKeys { widget_key, keys } => {
3733 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3735 let (prev_scroll, prev_sel) = match panel.instance_states.get(&widget_key) {
3736 Some(crate::widgets::WidgetInstanceState::Tree {
3737 scroll_offset,
3738 selected_index,
3739 ..
3740 }) => (*scroll_offset, *selected_index),
3741 _ => (0, -1),
3742 };
3743 let expanded: std::collections::HashSet<String> = keys.into_iter().collect();
3744 panel.instance_states.insert(
3745 widget_key,
3746 crate::widgets::WidgetInstanceState::Tree {
3747 scroll_offset: prev_scroll,
3748 selected_index: prev_sel,
3749 expanded_keys: expanded,
3750 },
3751 );
3752 }
3753 }
3754 WidgetMutation::SetCheckedKeys {
3755 widget_key,
3756 checked,
3757 keys,
3758 } => {
3759 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3767 crate::widgets::set_tree_checked_keys_in_spec(
3768 &mut panel.spec,
3769 &widget_key,
3770 checked,
3771 &keys,
3772 );
3773 }
3774 }
3775 }
3776
3777 self.rerender_widget_panel(panel_id);
3781 }
3782
3783 pub(super) fn handle_widget_command(
3784 &mut self,
3785 panel_id: u64,
3786 action: fresh_core::api::WidgetAction,
3787 ) {
3788 use fresh_core::api::WidgetAction;
3789 match action {
3790 WidgetAction::FocusAdvance { delta } => {
3791 self.handle_widget_focus_advance(panel_id, delta);
3792 }
3793 WidgetAction::Activate => {
3794 self.handle_widget_activate(panel_id);
3795 }
3796 WidgetAction::SelectMove { delta } => {
3797 self.handle_widget_select_move(panel_id, delta);
3798 }
3799 WidgetAction::TextInputKey { key } => {
3800 self.handle_widget_text_key(panel_id, &key);
3801 }
3802 WidgetAction::TextInputChar { text } => {
3803 self.handle_widget_text_char(panel_id, &text);
3804 }
3805 WidgetAction::Key { key } => {
3806 self.handle_widget_key(panel_id, &key);
3807 }
3808 }
3809 }
3810
3811 fn handle_widget_key(&mut self, panel_id: u64, key: &str) {
3812 let panel = match self.widget_registry.get(panel_id) {
3816 Some(p) => p,
3817 None => return,
3818 };
3819 let focus_key = panel.focus_key.clone();
3820 let widget = if focus_key.is_empty() {
3821 None
3822 } else {
3823 crate::widgets::find_widget_by_key(&panel.spec, &focus_key)
3824 };
3825 match key {
3826 "Tab" => self.handle_widget_focus_advance(panel_id, 1),
3827 "Shift+Tab" => self.handle_widget_focus_advance(panel_id, -1),
3828 "Up" | "Down" => {
3829 let delta = if key == "Up" { -1 } else { 1 };
3830 match widget {
3831 Some(fresh_core::api::WidgetSpec::List { .. }) => {
3832 self.handle_widget_select_move(panel_id, delta);
3833 }
3834 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
3835 self.handle_widget_tree_select_move(panel_id, delta);
3836 }
3837 Some(fresh_core::api::WidgetSpec::Text { rows, .. }) if *rows > 1 => {
3838 self.handle_widget_text_key(panel_id, key);
3844 }
3845 _ => {
3846 let scrollable = self
3854 .widget_registry
3855 .get(panel_id)
3856 .and_then(|p| find_scrollable_widget_key(&p.spec));
3857 if let Some(target_key) = scrollable {
3858 let target_kind = self.widget_registry.get(panel_id).and_then(|p| {
3859 crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
3860 });
3861 match target_kind {
3862 Some(fresh_core::api::WidgetSpec::List { .. }) => {
3863 self.handle_widget_select_move_for_key(
3864 panel_id,
3865 &target_key,
3866 delta,
3867 );
3868 }
3869 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
3870 self.handle_widget_tree_select_move_for_key(
3871 panel_id,
3872 &target_key,
3873 delta,
3874 );
3875 }
3876 _ => {}
3877 }
3878 }
3879 }
3880 }
3881 }
3882 "PageUp" | "PageDown" => {
3883 let page = match widget {
3887 Some(fresh_core::api::WidgetSpec::List { visible_rows, .. })
3888 | Some(fresh_core::api::WidgetSpec::Tree { visible_rows, .. }) => {
3889 visible_rows.saturating_sub(1).max(1) as i32
3890 }
3891 _ => 0,
3892 };
3893 if page == 0 {
3894 return;
3895 }
3896 let delta = if key == "PageUp" { -page } else { page };
3897 match widget {
3898 Some(fresh_core::api::WidgetSpec::List { .. }) => {
3899 self.handle_widget_select_move(panel_id, delta);
3900 }
3901 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
3902 self.handle_widget_tree_select_move(panel_id, delta);
3903 }
3904 _ => {}
3905 }
3906 }
3907 "Left" | "Right" => match widget {
3908 Some(fresh_core::api::WidgetSpec::Text { .. }) => {
3909 self.handle_widget_text_key(panel_id, key);
3910 }
3911 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
3912 self.handle_widget_tree_lateral(panel_id, key == "Right");
3913 }
3914 _ => {}
3915 },
3916 "Backspace" | "Delete" | "Home" | "End" => match widget {
3917 Some(fresh_core::api::WidgetSpec::Text { .. }) => {
3918 self.handle_widget_text_key(panel_id, key);
3919 }
3920 _ => {}
3921 },
3922 "Enter" => match widget {
3923 Some(fresh_core::api::WidgetSpec::Button { .. })
3924 | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
3925 self.handle_widget_activate(panel_id);
3926 }
3927 Some(fresh_core::api::WidgetSpec::List { .. }) => {
3928 self.fire_list_activate(panel_id, &focus_key);
3929 }
3930 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
3931 self.fire_tree_activate(panel_id, &focus_key);
3932 }
3933 Some(fresh_core::api::WidgetSpec::Text { rows, .. }) => {
3934 if *rows > 1 {
3935 self.handle_widget_text_key(panel_id, "Enter");
3941 } else if let Some(target_key) = self
3942 .widget_registry
3943 .get(panel_id)
3944 .and_then(|p| find_scrollable_widget_key(&p.spec))
3945 {
3946 let kind = self.widget_registry.get(panel_id).and_then(|p| {
3952 crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
3953 });
3954 match kind {
3955 Some(fresh_core::api::WidgetSpec::List { .. }) => {
3956 self.fire_list_activate(panel_id, &target_key);
3957 }
3958 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
3959 self.fire_tree_activate(panel_id, &target_key);
3960 }
3961 _ => {}
3962 }
3963 } else {
3964 self.handle_widget_focus_advance(panel_id, 1);
3967 }
3968 }
3969 _ => {}
3970 },
3971 "Space" => match widget {
3972 Some(fresh_core::api::WidgetSpec::Button { .. })
3973 | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
3974 self.handle_widget_activate(panel_id);
3975 }
3976 Some(fresh_core::api::WidgetSpec::Text { .. }) => {
3977 self.handle_widget_text_char(panel_id, " ");
3978 }
3979 Some(fresh_core::api::WidgetSpec::List { .. }) => {
3980 self.fire_list_activate(panel_id, &focus_key);
3981 }
3982 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
3983 if !self.fire_tree_toggle_if_checkable(panel_id, &focus_key) {
3990 self.fire_tree_activate(panel_id, &focus_key);
3991 }
3992 }
3993 _ => {}
3994 },
3995 _ => {} }
3997 }
3998
3999 fn handle_widget_focus_advance(&mut self, panel_id: u64, delta: i32) {
4000 let panel = match self.widget_registry.get(panel_id) {
4001 Some(p) => p,
4002 None => return,
4003 };
4004 if panel.tabbable.is_empty() {
4005 return;
4006 }
4007 let cur_idx = panel
4008 .tabbable
4009 .iter()
4010 .position(|k| k == &panel.focus_key)
4011 .unwrap_or(0) as i32;
4012 let n = panel.tabbable.len() as i32;
4013 let new_idx = ((cur_idx + delta) % n + n) % n;
4014 let new_key = panel.tabbable[new_idx as usize].clone();
4015 self.widget_registry.set_focus_key(panel_id, new_key);
4016 self.rerender_widget_panel(panel_id);
4017 }
4018
4019 fn handle_widget_activate(&mut self, panel_id: u64) {
4020 let panel = match self.widget_registry.get(panel_id) {
4024 Some(p) => p,
4025 None => return,
4026 };
4027 let focus_key = panel.focus_key.clone();
4028 if focus_key.is_empty() {
4029 return;
4030 }
4031 let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
4032 let (event_type, payload) = match widget {
4033 Some(fresh_core::api::WidgetSpec::Button { .. }) => ("activate", serde_json::json!({})),
4034 Some(fresh_core::api::WidgetSpec::Toggle { checked, .. }) => {
4035 ("toggle", serde_json::json!({ "checked": !checked }))
4036 }
4037 _ => return,
4038 };
4039 if self
4040 .plugin_manager
4041 .read()
4042 .unwrap()
4043 .has_hook_handlers("widget_event")
4044 {
4045 self.plugin_manager.read().unwrap().run_hook(
4046 "widget_event",
4047 fresh_core::hooks::HookArgs::WidgetEvent {
4048 panel_id,
4049 widget_key: focus_key,
4050 event_type: event_type.to_string(),
4051 payload,
4052 },
4053 );
4054 }
4055 }
4056
4057 fn fire_list_activate(&mut self, panel_id: u64, focus_key: &str) {
4063 let panel = match self.widget_registry.get(panel_id) {
4064 Some(p) => p,
4065 None => return,
4066 };
4067 let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
4068 let (spec_sel, item_keys) = match widget {
4069 Some(fresh_core::api::WidgetSpec::List {
4070 selected_index,
4071 item_keys,
4072 ..
4073 }) => (*selected_index, item_keys.clone()),
4074 _ => return,
4075 };
4076 let sel = match panel.instance_states.get(focus_key) {
4077 Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
4078 *selected_index
4079 }
4080 _ => spec_sel,
4081 };
4082 if sel < 0 {
4083 return;
4084 }
4085 let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
4086 if self
4087 .plugin_manager
4088 .read()
4089 .unwrap()
4090 .has_hook_handlers("widget_event")
4091 {
4092 self.plugin_manager.read().unwrap().run_hook(
4093 "widget_event",
4094 fresh_core::hooks::HookArgs::WidgetEvent {
4095 panel_id,
4096 widget_key: focus_key.to_string(),
4097 event_type: "activate".into(),
4098 payload: serde_json::json!({
4099 "index": sel,
4100 "key": item_key,
4101 }),
4102 },
4103 );
4104 }
4105 }
4106
4107 fn handle_widget_select_move(&mut self, panel_id: u64, delta: i32) {
4108 let focus_key = match self.widget_registry.get(panel_id) {
4109 Some(p) => p.focus_key.clone(),
4110 None => return,
4111 };
4112 if focus_key.is_empty() {
4113 return;
4114 }
4115 self.handle_widget_select_move_for_key(panel_id, &focus_key, delta);
4116 }
4117
4118 fn handle_widget_select_move_for_key(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4124 let panel = match self.widget_registry.get(panel_id) {
4125 Some(p) => p,
4126 None => return,
4127 };
4128 let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4129 let (spec_sel, total, item_keys) = match widget {
4130 Some(fresh_core::api::WidgetSpec::List {
4131 selected_index,
4132 items,
4133 item_keys,
4134 ..
4135 }) => (*selected_index, items.len() as i32, item_keys.clone()),
4136 _ => return,
4137 };
4138 if total == 0 {
4139 return;
4140 }
4141 let cur_sel = match panel.instance_states.get(widget_key) {
4142 Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
4143 *selected_index
4144 }
4145 _ => spec_sel,
4146 };
4147 let raw = if cur_sel < 0 { 0 } else { cur_sel + delta };
4148 let new_sel = raw.clamp(0, total - 1);
4149 let new_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
4150 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4151 let cur_scroll = match panel_mut.instance_states.get(widget_key) {
4152 Some(crate::widgets::WidgetInstanceState::List { scroll_offset, .. }) => {
4153 *scroll_offset
4154 }
4155 _ => 0,
4156 };
4157 panel_mut.instance_states.insert(
4158 widget_key.to_string(),
4159 crate::widgets::WidgetInstanceState::List {
4160 scroll_offset: cur_scroll,
4161 selected_index: new_sel,
4162 },
4163 );
4164 }
4165 self.rerender_widget_panel(panel_id);
4166 if self
4167 .plugin_manager
4168 .read()
4169 .unwrap()
4170 .has_hook_handlers("widget_event")
4171 {
4172 self.plugin_manager.read().unwrap().run_hook(
4173 "widget_event",
4174 fresh_core::hooks::HookArgs::WidgetEvent {
4175 panel_id,
4176 widget_key: widget_key.to_string(),
4177 event_type: "select".into(),
4178 payload: serde_json::json!({ "index": new_sel, "key": new_key }),
4179 },
4180 );
4181 }
4182 }
4183
4184 fn handle_widget_tree_select_move(&mut self, panel_id: u64, delta: i32) {
4189 let focus_key = match self.widget_registry.get(panel_id) {
4190 Some(p) => p.focus_key.clone(),
4191 None => return,
4192 };
4193 if focus_key.is_empty() {
4194 return;
4195 }
4196 self.handle_widget_tree_select_move_for_key(panel_id, &focus_key, delta);
4197 }
4198
4199 fn handle_widget_tree_select_move_for_key(
4201 &mut self,
4202 panel_id: u64,
4203 widget_key: &str,
4204 delta: i32,
4205 ) {
4206 let panel = match self.widget_registry.get(panel_id) {
4207 Some(p) => p,
4208 None => return,
4209 };
4210 let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4211 let (spec_sel, nodes, item_keys) = match widget {
4212 Some(fresh_core::api::WidgetSpec::Tree {
4213 selected_index,
4214 nodes,
4215 item_keys,
4216 ..
4217 }) => (*selected_index, nodes.clone(), item_keys.clone()),
4218 _ => return,
4219 };
4220 if nodes.is_empty() {
4221 return;
4222 }
4223 let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
4224 Some(crate::widgets::WidgetInstanceState::Tree {
4225 selected_index,
4226 scroll_offset,
4227 expanded_keys,
4228 }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
4229 _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
4230 };
4231 let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
4232 if visible_indices.is_empty() {
4233 return;
4234 }
4235 let cur_pos = if cur_sel < 0 {
4236 if delta > 0 {
4237 -1
4238 } else {
4239 visible_indices.len() as i32
4240 }
4241 } else {
4242 visible_indices
4243 .iter()
4244 .position(|&v| v as i32 == cur_sel)
4245 .map(|p| p as i32)
4246 .unwrap_or(-1)
4247 };
4248 let new_pos = (cur_pos + delta).clamp(0, (visible_indices.len() as i32) - 1);
4249 let new_abs = visible_indices[new_pos as usize];
4250 let new_key = item_keys.get(new_abs).cloned().unwrap_or_default();
4251 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4252 panel_mut.instance_states.insert(
4253 widget_key.to_string(),
4254 crate::widgets::WidgetInstanceState::Tree {
4255 scroll_offset: cur_scroll,
4256 selected_index: new_abs as i32,
4257 expanded_keys: expanded,
4258 },
4259 );
4260 }
4261 self.rerender_widget_panel(panel_id);
4262 if self
4263 .plugin_manager
4264 .read()
4265 .unwrap()
4266 .has_hook_handlers("widget_event")
4267 {
4268 self.plugin_manager.read().unwrap().run_hook(
4269 "widget_event",
4270 fresh_core::hooks::HookArgs::WidgetEvent {
4271 panel_id,
4272 widget_key: widget_key.to_string(),
4273 event_type: "select".into(),
4274 payload: serde_json::json!({ "index": new_abs as i64, "key": new_key }),
4275 },
4276 );
4277 }
4278 }
4279
4280 pub(super) fn handle_widget_panel_wheel(
4290 &mut self,
4291 buffer_id: crate::model::event::BufferId,
4292 delta: i32,
4293 ) -> bool {
4294 let panels = self.widget_registry.panels_for_buffer(buffer_id);
4295 let mut consumed = false;
4296 for panel_id in panels {
4297 let spec = match self.widget_registry.get(panel_id) {
4298 Some(p) => p.spec.clone(),
4299 None => continue,
4300 };
4301 let Some(widget_key) = find_scrollable_widget_key(&spec) else {
4302 continue;
4303 };
4304 let widget = crate::widgets::find_widget_by_key(&spec, &widget_key);
4305 match widget {
4306 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4307 self.handle_widget_tree_wheel(panel_id, &widget_key, delta);
4308 consumed = true;
4309 }
4310 Some(fresh_core::api::WidgetSpec::List { .. }) => {
4311 self.handle_widget_list_wheel(panel_id, &widget_key, delta);
4312 consumed = true;
4313 }
4314 _ => {}
4315 }
4316 }
4317 consumed
4318 }
4319
4320 fn handle_widget_tree_wheel(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4325 let panel = match self.widget_registry.get(panel_id) {
4326 Some(p) => p,
4327 None => return,
4328 };
4329 let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4330 let (visible_rows, nodes, item_keys) = match widget {
4331 Some(fresh_core::api::WidgetSpec::Tree {
4332 visible_rows,
4333 nodes,
4334 item_keys,
4335 ..
4336 }) => (*visible_rows, nodes.clone(), item_keys.clone()),
4337 _ => return,
4338 };
4339 if nodes.is_empty() {
4340 return;
4341 }
4342 let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
4343 Some(crate::widgets::WidgetInstanceState::Tree {
4344 selected_index,
4345 scroll_offset,
4346 expanded_keys,
4347 }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
4348 _ => (-1, 0, std::collections::HashSet::<String>::new()),
4349 };
4350 let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
4351 if visible_indices.is_empty() {
4352 return;
4353 }
4354 let visible = visible_rows.max(1);
4355 let total_visible = visible_indices.len() as u32;
4356 let max_scroll = total_visible.saturating_sub(visible);
4357 let new_scroll = (cur_scroll as i32 + delta).clamp(0, max_scroll as i32) as u32;
4358 if new_scroll == cur_scroll {
4359 return;
4360 }
4361 let cur_pos: Option<u32> = if cur_sel >= 0 {
4363 visible_indices
4364 .iter()
4365 .position(|&v| v as i32 == cur_sel)
4366 .map(|p| p as u32)
4367 } else {
4368 None
4369 };
4370 let new_sel_abs = match cur_pos {
4371 Some(pos) if pos < new_scroll => visible_indices[new_scroll as usize] as i32,
4372 Some(pos) if pos >= new_scroll + visible => {
4373 visible_indices[(new_scroll + visible - 1) as usize] as i32
4374 }
4375 _ => cur_sel,
4376 };
4377 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4378 panel_mut.instance_states.insert(
4379 widget_key.to_string(),
4380 crate::widgets::WidgetInstanceState::Tree {
4381 scroll_offset: new_scroll,
4382 selected_index: new_sel_abs,
4383 expanded_keys: expanded,
4384 },
4385 );
4386 }
4387 self.rerender_widget_panel(panel_id);
4388 }
4389
4390 fn handle_widget_list_wheel(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4392 let panel = match self.widget_registry.get(panel_id) {
4393 Some(p) => p,
4394 None => return,
4395 };
4396 let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4397 let (visible_rows, total) = match widget {
4398 Some(fresh_core::api::WidgetSpec::List {
4399 visible_rows,
4400 items,
4401 ..
4402 }) => (*visible_rows, items.len() as u32),
4403 _ => return,
4404 };
4405 if total == 0 {
4406 return;
4407 }
4408 let (cur_sel, cur_scroll) = match panel.instance_states.get(widget_key) {
4409 Some(crate::widgets::WidgetInstanceState::List {
4410 selected_index,
4411 scroll_offset,
4412 }) => (*selected_index, *scroll_offset),
4413 _ => (-1, 0),
4414 };
4415 let visible = visible_rows.max(1);
4416 let max_scroll = total.saturating_sub(visible);
4417 let new_scroll = (cur_scroll as i32 + delta).clamp(0, max_scroll as i32) as u32;
4418 if new_scroll == cur_scroll {
4419 return;
4420 }
4421 let new_sel = if cur_sel < 0 {
4422 cur_sel
4423 } else if (cur_sel as u32) < new_scroll {
4424 new_scroll as i32
4425 } else if (cur_sel as u32) >= new_scroll + visible {
4426 (new_scroll + visible - 1) as i32
4427 } else {
4428 cur_sel
4429 };
4430 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4431 panel_mut.instance_states.insert(
4432 widget_key.to_string(),
4433 crate::widgets::WidgetInstanceState::List {
4434 scroll_offset: new_scroll,
4435 selected_index: new_sel,
4436 },
4437 );
4438 }
4439 self.rerender_widget_panel(panel_id);
4440 }
4441
4442 fn handle_widget_tree_lateral(&mut self, panel_id: u64, is_right: bool) {
4452 let panel = match self.widget_registry.get(panel_id) {
4453 Some(p) => p,
4454 None => return,
4455 };
4456 let focus_key = panel.focus_key.clone();
4457 if focus_key.is_empty() {
4458 return;
4459 }
4460 let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
4461 let (spec_sel, nodes, item_keys) = match widget {
4462 Some(fresh_core::api::WidgetSpec::Tree {
4463 selected_index,
4464 nodes,
4465 item_keys,
4466 ..
4467 }) => (*selected_index, nodes.clone(), item_keys.clone()),
4468 _ => return,
4469 };
4470 if nodes.is_empty() {
4471 return;
4472 }
4473 let (cur_sel, cur_scroll, mut expanded) = match panel.instance_states.get(&focus_key) {
4474 Some(crate::widgets::WidgetInstanceState::Tree {
4475 selected_index,
4476 scroll_offset,
4477 expanded_keys,
4478 }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
4479 _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
4480 };
4481 if cur_sel < 0 {
4482 return;
4483 }
4484 let sel_idx = cur_sel as usize;
4485 let node = match nodes.get(sel_idx) {
4486 Some(n) => n,
4487 None => return,
4488 };
4489 let key = item_keys.get(sel_idx).cloned().unwrap_or_default();
4490 let was_expanded = !key.is_empty() && expanded.contains(&key);
4491
4492 let mut new_sel = cur_sel;
4493 let mut expansion_changed: Option<bool> = None; if is_right {
4495 if node.has_children && !was_expanded && !key.is_empty() {
4496 expanded.insert(key.clone());
4497 expansion_changed = Some(true);
4498 }
4499 } else if node.has_children && was_expanded && !key.is_empty() {
4500 expanded.remove(&key);
4501 expansion_changed = Some(false);
4502 } else if let Some(parent_idx) = crate::widgets::tree_parent_index(&nodes, sel_idx) {
4503 new_sel = parent_idx as i32;
4504 }
4505 if expansion_changed.is_none() && new_sel == cur_sel {
4507 return;
4508 }
4509 let final_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
4510 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4511 panel_mut.instance_states.insert(
4512 focus_key.clone(),
4513 crate::widgets::WidgetInstanceState::Tree {
4514 scroll_offset: cur_scroll,
4515 selected_index: new_sel,
4516 expanded_keys: expanded,
4517 },
4518 );
4519 }
4520 self.rerender_widget_panel(panel_id);
4521 if self
4522 .plugin_manager
4523 .read()
4524 .unwrap()
4525 .has_hook_handlers("widget_event")
4526 {
4527 if let Some(now_expanded) = expansion_changed {
4528 self.plugin_manager.read().unwrap().run_hook(
4529 "widget_event",
4530 fresh_core::hooks::HookArgs::WidgetEvent {
4531 panel_id,
4532 widget_key: focus_key.clone(),
4533 event_type: "expand".into(),
4534 payload: serde_json::json!({
4535 "index": cur_sel as i64,
4536 "key": key,
4537 "expanded": now_expanded,
4538 }),
4539 },
4540 );
4541 } else if new_sel != cur_sel {
4542 self.plugin_manager.read().unwrap().run_hook(
4543 "widget_event",
4544 fresh_core::hooks::HookArgs::WidgetEvent {
4545 panel_id,
4546 widget_key: focus_key,
4547 event_type: "select".into(),
4548 payload: serde_json::json!({
4549 "index": new_sel as i64,
4550 "key": final_key,
4551 }),
4552 },
4553 );
4554 }
4555 }
4556 }
4557
4558 pub(crate) fn handle_widget_tree_expand_toggle(
4562 &mut self,
4563 panel_id: u64,
4564 widget_key: &str,
4565 item_key: &str,
4566 ) {
4567 if widget_key.is_empty() || item_key.is_empty() {
4568 return;
4569 }
4570 let now_expanded = {
4571 let panel = match self.widget_registry.get_mut(panel_id) {
4572 Some(p) => p,
4573 None => return,
4574 };
4575 let (cur_scroll, cur_sel, mut expanded) = match panel.instance_states.get(widget_key) {
4576 Some(crate::widgets::WidgetInstanceState::Tree {
4577 scroll_offset,
4578 selected_index,
4579 expanded_keys,
4580 }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
4581 _ => (0u32, -1i32, std::collections::HashSet::<String>::new()),
4582 };
4583 let next = if expanded.contains(item_key) {
4584 expanded.remove(item_key);
4585 false
4586 } else {
4587 expanded.insert(item_key.to_string());
4588 true
4589 };
4590 panel.instance_states.insert(
4591 widget_key.to_string(),
4592 crate::widgets::WidgetInstanceState::Tree {
4593 scroll_offset: cur_scroll,
4594 selected_index: cur_sel,
4595 expanded_keys: expanded,
4596 },
4597 );
4598 next
4599 };
4600 self.rerender_widget_panel(panel_id);
4601 if self
4602 .plugin_manager
4603 .read()
4604 .unwrap()
4605 .has_hook_handlers("widget_event")
4606 {
4607 self.plugin_manager.read().unwrap().run_hook(
4608 "widget_event",
4609 fresh_core::hooks::HookArgs::WidgetEvent {
4610 panel_id,
4611 widget_key: widget_key.to_string(),
4612 event_type: "expand".into(),
4613 payload: serde_json::json!({
4614 "key": item_key,
4615 "expanded": now_expanded,
4616 }),
4617 },
4618 );
4619 }
4620 }
4621
4622 fn fire_tree_toggle_if_checkable(&mut self, panel_id: u64, focus_key: &str) -> bool {
4636 let panel = match self.widget_registry.get(panel_id) {
4637 Some(p) => p,
4638 None => return false,
4639 };
4640 let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
4641 let (spec_sel, nodes, item_keys, checkable) = match widget {
4642 Some(fresh_core::api::WidgetSpec::Tree {
4643 selected_index,
4644 nodes,
4645 item_keys,
4646 checkable,
4647 ..
4648 }) => (*selected_index, nodes, item_keys.clone(), *checkable),
4649 _ => return false,
4650 };
4651 if !checkable {
4652 return false;
4653 }
4654 let sel = match panel.instance_states.get(focus_key) {
4655 Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
4656 *selected_index
4657 }
4658 _ => spec_sel,
4659 };
4660 if sel < 0 {
4661 return false;
4662 }
4663 let cur_checked = match nodes.get(sel as usize).and_then(|n| n.checked) {
4664 Some(b) => b,
4665 None => return false, };
4667 let new_checked = !cur_checked;
4668 let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
4669 if self
4670 .plugin_manager
4671 .read()
4672 .unwrap()
4673 .has_hook_handlers("widget_event")
4674 {
4675 self.plugin_manager.read().unwrap().run_hook(
4676 "widget_event",
4677 fresh_core::hooks::HookArgs::WidgetEvent {
4678 panel_id,
4679 widget_key: focus_key.to_string(),
4680 event_type: "toggle".into(),
4681 payload: serde_json::json!({
4682 "index": sel,
4683 "key": item_key,
4684 "checked": new_checked,
4685 }),
4686 },
4687 );
4688 }
4689 true
4690 }
4691
4692 fn fire_tree_activate(&mut self, panel_id: u64, focus_key: &str) {
4693 let panel = match self.widget_registry.get(panel_id) {
4694 Some(p) => p,
4695 None => return,
4696 };
4697 let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
4698 let (spec_sel, item_keys) = match widget {
4699 Some(fresh_core::api::WidgetSpec::Tree {
4700 selected_index,
4701 item_keys,
4702 ..
4703 }) => (*selected_index, item_keys.clone()),
4704 _ => return,
4705 };
4706 let sel = match panel.instance_states.get(focus_key) {
4707 Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
4708 *selected_index
4709 }
4710 _ => spec_sel,
4711 };
4712 if sel < 0 {
4713 return;
4714 }
4715 let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
4716 if self
4717 .plugin_manager
4718 .read()
4719 .unwrap()
4720 .has_hook_handlers("widget_event")
4721 {
4722 self.plugin_manager.read().unwrap().run_hook(
4723 "widget_event",
4724 fresh_core::hooks::HookArgs::WidgetEvent {
4725 panel_id,
4726 widget_key: focus_key.to_string(),
4727 event_type: "activate".into(),
4728 payload: serde_json::json!({
4729 "index": sel,
4730 "key": item_key,
4731 }),
4732 },
4733 );
4734 }
4735 }
4736
4737 fn read_focused_text(&self, panel_id: u64) -> Option<(String, FocusedText)> {
4743 let panel = self.widget_registry.get(panel_id)?;
4744 let focus_key = panel.focus_key.clone();
4745 if focus_key.is_empty() {
4746 return None;
4747 }
4748 let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key)?;
4755 let (spec_value, spec_cursor, multiline) = match widget {
4756 fresh_core::api::WidgetSpec::Text {
4757 value,
4758 cursor_byte,
4759 rows,
4760 ..
4761 } => (value, *cursor_byte, *rows > 1),
4762 _ => return None,
4763 };
4764 if let Some(crate::widgets::WidgetInstanceState::Text {
4766 value,
4767 cursor_byte,
4768 scroll,
4769 }) = panel.instance_states.get(&focus_key)
4770 {
4771 return Some((
4772 focus_key,
4773 FocusedText {
4774 value: value.clone(),
4775 cursor: *cursor_byte as usize,
4776 scroll: *scroll,
4777 multiline,
4778 },
4779 ));
4780 }
4781 let cur = if spec_cursor < 0 {
4785 spec_value.len()
4786 } else {
4787 (spec_cursor as usize).min(spec_value.len())
4788 };
4789 Some((
4790 focus_key,
4791 FocusedText {
4792 value: spec_value.clone(),
4793 cursor: cur,
4794 scroll: 0,
4795 multiline,
4796 },
4797 ))
4798 }
4799
4800 fn write_focused_text(
4805 &mut self,
4806 panel_id: u64,
4807 focus_key: &str,
4808 prev: &FocusedText,
4809 new_value: String,
4810 new_cursor: usize,
4811 ) {
4812 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
4813 panel.instance_states.insert(
4814 focus_key.to_string(),
4815 crate::widgets::WidgetInstanceState::Text {
4816 value: new_value.clone(),
4817 cursor_byte: new_cursor as u32,
4818 scroll: prev.scroll,
4819 },
4820 );
4821 }
4822 self.rerender_widget_panel(panel_id);
4823 if self
4824 .plugin_manager
4825 .read()
4826 .unwrap()
4827 .has_hook_handlers("widget_event")
4828 {
4829 self.plugin_manager.read().unwrap().run_hook(
4830 "widget_event",
4831 fresh_core::hooks::HookArgs::WidgetEvent {
4832 panel_id,
4833 widget_key: focus_key.to_string(),
4834 event_type: "change".into(),
4835 payload: serde_json::json!({
4836 "value": new_value,
4837 "cursorByte": new_cursor as i64,
4838 }),
4839 },
4840 );
4841 }
4842 }
4843
4844 fn handle_widget_text_key(&mut self, panel_id: u64, key: &str) {
4850 let (focus_key, prev) = match self.read_focused_text(panel_id) {
4851 Some(t) => t,
4852 None => return,
4853 };
4854 let (new_value, new_cursor) =
4855 crate::widgets::apply_text_key(prev.value(), prev.cursor(), key, prev.multiline);
4856 if new_value == *prev.value() && new_cursor == prev.cursor() {
4857 return; }
4859 self.write_focused_text(panel_id, &focus_key, &prev, new_value, new_cursor);
4860 }
4861
4862 fn handle_widget_text_char(&mut self, panel_id: u64, text: &str) {
4869 if text.is_empty() {
4870 return;
4871 }
4872 let (focus_key, prev) = match self.read_focused_text(panel_id) {
4873 Some(t) => t,
4874 None => return,
4875 };
4876 let (new_value, new_cursor) =
4877 crate::widgets::apply_text_char(prev.value(), prev.cursor(), text);
4878 self.write_focused_text(panel_id, &focus_key, &prev, new_value, new_cursor);
4879 }
4880
4881 fn handle_unmount_widget_panel(&mut self, panel_id: u64) {
4882 match self.widget_registry.unmount(panel_id) {
4883 Some(buffer_id) => {
4884 tracing::debug!(
4885 "Unmounted widget panel {} (was rendering into {:?})",
4886 panel_id,
4887 buffer_id
4888 );
4889 }
4894 None => {
4895 tracing::debug!("UnmountWidgetPanel for unknown panel {} ignored", panel_id);
4896 }
4897 }
4898 }
4899
4900 fn handle_mount_floating_widget(
4901 &mut self,
4902 panel_id: u64,
4903 spec: fresh_core::api::WidgetSpec,
4904 width_pct: u8,
4905 height_pct: u8,
4906 ) {
4907 let width_pct = width_pct.clamp(1, 100);
4908 let height_pct = height_pct.clamp(1, 100);
4909 if let Some(existing) = self.floating_widget_panel.take() {
4910 if existing.panel_id != panel_id {
4911 let _ = self.widget_registry.unmount(existing.panel_id);
4912 }
4913 }
4914 self.floating_widget_panel = Some(FloatingWidgetState {
4915 panel_id,
4916 width_pct,
4917 height_pct,
4918 entries: Vec::new(),
4919 focus_cursor: None,
4920 embeds: Vec::new(),
4921 last_inner_rect: None,
4922 });
4923 let prev = std::collections::HashMap::new();
4924 let prev_focus = String::new();
4925 let panel_width = self.floating_panel_inner_width();
4926 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
4927 let focus_cursor = out.focus_cursor;
4928 let entries = out.entries;
4929 let embeds = out.embeds;
4930 self.widget_registry.mount(
4931 panel_id,
4932 FLOATING_PANEL_BUFFER_ID,
4933 spec,
4934 out.hits,
4935 out.instance_states,
4936 out.focus_key,
4937 out.tabbable,
4938 );
4939 if let Some(fwp) = self.floating_widget_panel.as_mut() {
4940 fwp.entries = entries;
4941 fwp.focus_cursor = focus_cursor;
4942 fwp.embeds = embeds;
4943 }
4944 tracing::debug!(
4945 "Mounted floating widget panel {} ({}%x{}%)",
4946 panel_id,
4947 width_pct,
4948 height_pct
4949 );
4950 }
4951
4952 fn handle_update_floating_widget(&mut self, panel_id: u64, spec: fresh_core::api::WidgetSpec) {
4953 match self.floating_widget_panel.as_ref() {
4954 Some(fwp) if fwp.panel_id == panel_id => {}
4955 _ => {
4956 tracing::debug!(
4957 "UpdateFloatingWidget for unknown / mismatched panel {} ignored",
4958 panel_id
4959 );
4960 return;
4961 }
4962 }
4963 let prev = self
4964 .widget_registry
4965 .instance_states(panel_id)
4966 .cloned()
4967 .unwrap_or_default();
4968 let prev_focus = self
4969 .widget_registry
4970 .focus_key(panel_id)
4971 .map(|s| s.to_string())
4972 .unwrap_or_default();
4973 let panel_width = self.floating_panel_inner_width();
4974 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
4975 let focus_cursor = out.focus_cursor;
4976 let entries = out.entries;
4977 let embeds = out.embeds;
4978 if self
4979 .widget_registry
4980 .update(
4981 panel_id,
4982 spec,
4983 out.hits,
4984 out.instance_states,
4985 out.focus_key,
4986 out.tabbable,
4987 )
4988 .is_err()
4989 {
4990 tracing::debug!(
4991 "UpdateFloatingWidget for unknown panel {} ignored (not in registry)",
4992 panel_id
4993 );
4994 return;
4995 }
4996 if let Some(fwp) = self.floating_widget_panel.as_mut() {
4997 fwp.entries = entries;
4998 fwp.focus_cursor = focus_cursor;
4999 fwp.embeds = embeds;
5000 }
5001 }
5002
5003 fn handle_unmount_floating_widget(&mut self, panel_id: u64) {
5004 match self.floating_widget_panel.as_ref() {
5005 Some(fwp) if fwp.panel_id == panel_id => {}
5006 _ => {
5007 tracing::debug!(
5008 "UnmountFloatingWidget for unknown / mismatched panel {} ignored",
5009 panel_id
5010 );
5011 return;
5012 }
5013 }
5014 self.floating_widget_panel = None;
5015 let _ = self.widget_registry.unmount(panel_id);
5016 tracing::debug!("Unmounted floating widget panel {}", panel_id);
5017 }
5018
5019 pub(super) fn floating_panel_inner_width(&self) -> u32 {
5025 let term_w = self.terminal_width.max(1) as u32;
5026 let pct = self
5027 .floating_widget_panel
5028 .as_ref()
5029 .map(|f| f.width_pct.clamp(1, 100) as u32)
5030 .unwrap_or(80);
5031 let w = (term_w * pct) / 100;
5032 w.saturating_sub(2).max(10)
5033 }
5034
5035 fn handle_get_text_properties_at_cursor(&self, buffer_id: BufferId) {
5036 if let Some(state) = self
5037 .windows
5038 .get(&self.active_window)
5039 .map(|w| &w.buffers)
5040 .expect("active window present")
5041 .get(&buffer_id)
5042 {
5043 let cursor_pos = self
5044 .windows
5045 .get(&self.active_window)
5046 .and_then(|w| w.buffers.splits())
5047 .map(|(_, vs)| vs)
5048 .expect("active window must have a populated split layout")
5049 .values()
5050 .find_map(|vs| vs.buffer_state(buffer_id))
5051 .map(|bs| bs.cursors.primary().position)
5052 .unwrap_or(0);
5053 let properties = state.text_properties.get_at(cursor_pos);
5054 tracing::debug!(
5055 "Text properties at cursor in {:?}: {} properties found",
5056 buffer_id,
5057 properties.len()
5058 );
5059 }
5061 }
5062
5063 fn handle_set_context(&mut self, name: String, active: bool) {
5064 if active {
5065 self.active_window_mut()
5066 .active_custom_contexts
5067 .insert(name.clone());
5068 tracing::debug!("Set custom context: {}", name);
5069 } else {
5070 self.active_window_mut()
5071 .active_custom_contexts
5072 .remove(&name);
5073 tracing::debug!("Unset custom context: {}", name);
5074 }
5075 }
5076
5077 fn handle_disable_lsp_for_language(&mut self, language: String) {
5078 tracing::info!("Disabling LSP for language: {}", language);
5079 let __active_id = self.active_window;
5080 if let Some(lsp) = self
5081 .windows
5082 .get_mut(&__active_id)
5083 .and_then(|w| w.lsp.as_mut())
5084 {
5085 lsp.shutdown_server(&language);
5086 tracing::info!("Stopped LSP server for {}", language);
5087 }
5088 if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
5089 for c in lsp_configs.as_mut_slice() {
5090 c.enabled = false;
5091 c.auto_start = false;
5092 }
5093 tracing::info!("Disabled LSP config for {}", language);
5094 }
5095 if let Err(e) = self.save_config() {
5096 tracing::error!("Failed to save config: {}", e);
5097 self.active_window_mut().status_message = Some(format!(
5098 "LSP disabled for {} (config save failed)",
5099 language
5100 ));
5101 } else {
5102 self.active_window_mut().status_message =
5103 Some(format!("LSP disabled for {}", language));
5104 }
5105 self.active_window_mut().warning_domains.lsp.clear();
5106 }
5107
5108 fn handle_restart_lsp_for_language(&mut self, language: String) {
5109 tracing::info!("Plugin restarting LSP for language: {}", language);
5110 let file_path = self
5111 .active_window()
5112 .buffer_metadata
5113 .get(&self.active_buffer())
5114 .and_then(|meta| meta.file_path().cloned());
5115 let __active_id = self.active_window;
5116 let success = if let Some(lsp) = self
5117 .windows
5118 .get_mut(&__active_id)
5119 .and_then(|w| w.lsp.as_mut())
5120 {
5121 let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
5122 self.active_window_mut().status_message = Some(msg);
5123 ok
5124 } else {
5125 self.active_window_mut().status_message = Some("No LSP manager available".to_string());
5126 false
5127 };
5128 if success {
5129 self.reopen_buffers_for_language(&language);
5130 }
5131 }
5132
5133 fn handle_set_lsp_root_uri(&mut self, language: String, uri: String) {
5134 tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
5135 match uri.parse::<lsp_types::Uri>() {
5136 Ok(parsed_uri) => {
5137 let __active_id = self.active_window;
5138 if let Some(lsp) = self
5139 .windows
5140 .get_mut(&__active_id)
5141 .and_then(|w| w.lsp.as_mut())
5142 {
5143 let restarted = lsp.set_language_root_uri(&language, parsed_uri);
5144 if restarted {
5145 self.active_window_mut().status_message = Some(format!(
5146 "LSP root updated for {} (restarting server)",
5147 language
5148 ));
5149 } else {
5150 self.active_window_mut().status_message =
5151 Some(format!("LSP root set for {}", language));
5152 }
5153 }
5154 }
5155 Err(e) => {
5156 tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
5157 self.active_window_mut().status_message =
5158 Some(format!("Invalid LSP root URI: {}", e));
5159 }
5160 }
5161 }
5162
5163 fn handle_create_scroll_sync_group(
5164 &mut self,
5165 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5166 left_split: SplitId,
5167 right_split: SplitId,
5168 ) {
5169 let success = self
5170 .active_window_mut()
5171 .scroll_sync_manager
5172 .create_group_with_id(group_id, left_split, right_split);
5173 if success {
5174 tracing::debug!(
5175 "Created scroll sync group {} for splits {:?} and {:?}",
5176 group_id,
5177 left_split,
5178 right_split
5179 );
5180 } else {
5181 tracing::warn!(
5182 "Failed to create scroll sync group {} (ID already exists)",
5183 group_id
5184 );
5185 }
5186 }
5187
5188 fn handle_set_scroll_sync_anchors(
5189 &mut self,
5190 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5191 anchors: Vec<(usize, usize)>,
5192 ) {
5193 use crate::view::scroll_sync::SyncAnchor;
5194 let anchor_count = anchors.len();
5195 let sync_anchors: Vec<SyncAnchor> = anchors
5196 .into_iter()
5197 .map(|(left_line, right_line)| SyncAnchor {
5198 left_line,
5199 right_line,
5200 })
5201 .collect();
5202 self.active_window_mut()
5203 .scroll_sync_manager
5204 .set_anchors(group_id, sync_anchors);
5205 tracing::debug!(
5206 "Set {} anchors for scroll sync group {}",
5207 anchor_count,
5208 group_id
5209 );
5210 }
5211
5212 fn handle_remove_scroll_sync_group(
5213 &mut self,
5214 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5215 ) {
5216 if self
5217 .active_window_mut()
5218 .scroll_sync_manager
5219 .remove_group(group_id)
5220 {
5221 tracing::debug!("Removed scroll sync group {}", group_id);
5222 } else {
5223 tracing::warn!("Scroll sync group {} not found", group_id);
5224 }
5225 }
5226
5227 fn handle_create_buffer_group(
5228 &mut self,
5229 name: String,
5230 mode: String,
5231 layout_json: String,
5232 request_id: Option<u64>,
5233 ) {
5234 match self.create_buffer_group(name, mode, layout_json) {
5235 Ok(result) => {
5236 if let Some(req_id) = request_id {
5237 let json = serde_json::to_string(&result).unwrap_or_default();
5238 self.plugin_manager
5239 .read()
5240 .unwrap()
5241 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
5242 }
5243 }
5244 Err(e) => {
5245 tracing::error!("Failed to create buffer group: {}", e);
5246 }
5247 }
5248 }
5249
5250 fn handle_send_terminal_input(
5251 &mut self,
5252 terminal_id: crate::services::terminal::TerminalId,
5253 data: String,
5254 ) {
5255 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
5256 handle.write(data.as_bytes());
5257 tracing::trace!(
5258 "Plugin sent {} bytes to terminal {:?}",
5259 data.len(),
5260 terminal_id
5261 );
5262 } else {
5263 tracing::warn!(
5264 "Plugin tried to send input to non-existent terminal {:?}",
5265 terminal_id
5266 );
5267 }
5268 }
5269
5270 fn handle_close_terminal(&mut self, terminal_id: crate::services::terminal::TerminalId) {
5271 let buffer_to_close = self
5272 .active_window()
5273 .terminal_buffers
5274 .iter()
5275 .find(|(_, &tid)| tid == terminal_id)
5276 .map(|(&bid, _)| bid);
5277 if let Some(buffer_id) = buffer_to_close {
5278 if let Err(e) = self.close_buffer(buffer_id) {
5279 tracing::warn!("Failed to close terminal buffer: {}", e);
5280 }
5281 tracing::info!("Plugin closed terminal {:?}", terminal_id);
5282 } else {
5283 self.active_window_mut().terminal_manager.close(terminal_id);
5284 tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
5285 }
5286 }
5287
5288 fn handle_signal_window(&mut self, id: fresh_core::WindowId, signal: &str) {
5296 let Some(window) = self.windows.get_mut(&id) else {
5297 tracing::warn!("Plugin SignalWindow targeted unknown window {:?}", id);
5298 return;
5299 };
5300 let results = window.process_groups.signal_all(signal);
5301 for (entry, result) in results {
5302 match result {
5303 Ok(true) => tracing::info!(
5304 "SignalWindow {:?}: {} → pid {} ({})",
5305 id,
5306 signal,
5307 entry.leader_pid,
5308 entry.label
5309 ),
5310 Ok(false) => tracing::debug!(
5311 "SignalWindow {:?}: pid {} ({}) already exited",
5312 id,
5313 entry.leader_pid,
5314 entry.label
5315 ),
5316 Err(e) => tracing::warn!(
5317 "SignalWindow {:?}: pid {} ({}): {}",
5318 id,
5319 entry.leader_pid,
5320 entry.label,
5321 e
5322 ),
5323 }
5324 }
5325 }
5326}
5327
5328#[cfg(test)]
5329mod tests {
5330 use tokio::io::{AsyncReadExt, BufReader};
5343 use tokio::process::Command as TokioCommand;
5344 use tokio::time::{timeout, Duration};
5345
5346 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5357 async fn kill_via_oneshot_terminates_long_running_child() {
5358 let mut cmd = TokioCommand::new("sleep");
5359 cmd.args(["30"]);
5360 cmd.stdout(std::process::Stdio::piped());
5361 cmd.stderr(std::process::Stdio::piped());
5362
5363 let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
5364 let pid = child.id().expect("child has a pid");
5365
5366 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
5367 let stdout_pipe = child.stdout.take();
5368 let stderr_pipe = child.stderr.take();
5369
5370 let stdout_fut = async {
5371 let mut buf = String::new();
5372 if let Some(s) = stdout_pipe {
5373 #[allow(clippy::let_underscore_must_use)]
5374 let _ = BufReader::new(s).read_to_string(&mut buf).await;
5375 }
5376 buf
5377 };
5378 let stderr_fut = async {
5379 let mut buf = String::new();
5380 if let Some(s) = stderr_pipe {
5381 #[allow(clippy::let_underscore_must_use)]
5382 let _ = BufReader::new(s).read_to_string(&mut buf).await;
5383 }
5384 buf
5385 };
5386 let wait_fut = async {
5387 tokio::select! {
5388 status = child.wait() => {
5389 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
5390 }
5391 _ = &mut kill_rx => {
5392 #[allow(clippy::let_underscore_must_use)]
5393 let _ = child.start_kill();
5394 child
5395 .wait()
5396 .await
5397 .map(|s| s.code().unwrap_or(-1))
5398 .unwrap_or(-1)
5399 }
5400 }
5401 };
5402
5403 tokio::time::sleep(Duration::from_millis(50)).await;
5408 kill_tx.send(()).expect("kill channel send");
5409
5410 let result = timeout(Duration::from_secs(5), async {
5411 tokio::join!(stdout_fut, stderr_fut, wait_fut)
5412 })
5413 .await;
5414
5415 let (_stdout, _stderr, exit_code) = result.expect(
5416 "kill path must resolve within 5s — if this times out the \
5417 select! arm order or kill-then-wait logic is broken",
5418 );
5419 assert_ne!(
5431 exit_code, 0,
5432 "killed child must exit non-success (got 0 — did the \
5433 kill arm fire too late, or did sleep somehow complete?)"
5434 );
5435
5436 #[cfg(unix)]
5445 {
5446 let still_alive = std::process::Command::new("kill")
5447 .args(["-0", &pid.to_string()])
5448 .status()
5449 .map(|s| s.success())
5450 .unwrap_or(false);
5451 assert!(
5452 !still_alive,
5453 "process {pid} must be reaped after wait() — a still-\
5454 alive check means the kill path leaked the child"
5455 );
5456 }
5457 #[cfg(not(unix))]
5458 {
5459 let _ = pid;
5462 }
5463 }
5464}
5465
5466impl Window {
5467 #[cfg(feature = "plugins")]
5482 pub(crate) fn populate_plugin_state_snapshot(
5483 &mut self,
5484 snapshot: &mut fresh_core::api::EditorStateSnapshot,
5485 ) {
5486 use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
5487
5488 let current_gen = self.resources.grammar_registry.catalog_gen();
5494 if snapshot.last_grammar_gen != current_gen {
5495 snapshot.available_grammars = self
5496 .resources
5497 .grammar_registry
5498 .available_grammar_info()
5499 .into_iter()
5500 .map(|g| fresh_core::api::GrammarInfoSnapshot {
5501 name: g.name,
5502 source: g.source.to_string(),
5503 file_extensions: g.file_extensions,
5504 short_name: g.short_name,
5505 })
5506 .collect();
5507 snapshot.last_grammar_gen = current_gen;
5508 }
5509
5510 snapshot.active_buffer_id = self.active_buffer();
5511
5512 let (mgr_ref, vs_ref) = self
5513 .buffers
5514 .splits()
5515 .expect("active window must have a populated split layout");
5516 let active_split = mgr_ref.active_split();
5517 snapshot.active_split_id = active_split.0 .0;
5518
5519 snapshot.buffers.clear();
5521 snapshot.buffer_saved_diffs.clear();
5522 snapshot.buffer_cursor_positions.clear();
5523 snapshot.buffer_text_properties.clear();
5524
5525 let active_vs_opt = vs_ref.get(&active_split);
5526 for (buffer_id, state) in &self.buffers {
5527 let is_virtual = self
5528 .buffer_metadata
5529 .get(buffer_id)
5530 .map(|m| m.is_virtual())
5531 .unwrap_or(false);
5532 let view_mode = active_vs_opt
5537 .and_then(|vs| vs.buffer_state(*buffer_id))
5538 .map(|bs| match bs.view_mode {
5539 crate::state::ViewMode::Source => "source",
5540 crate::state::ViewMode::PageView => "compose",
5541 })
5542 .unwrap_or("source");
5543 let compose_width = active_vs_opt
5544 .and_then(|vs| vs.buffer_state(*buffer_id))
5545 .and_then(|bs| bs.compose_width);
5546 let is_composing_in_any_split = vs_ref.values().any(|vs| {
5547 vs.buffer_state(*buffer_id)
5548 .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
5549 .unwrap_or(false)
5550 });
5551 let is_preview = self
5552 .buffer_metadata
5553 .get(buffer_id)
5554 .map(|m| m.is_preview)
5555 .unwrap_or(false);
5556 let splits: Vec<fresh_core::SplitId> = mgr_ref
5562 .splits_for_buffer(*buffer_id)
5563 .into_iter()
5564 .map(|leaf_id| leaf_id.0)
5565 .collect();
5566 let buffer_info = BufferInfo {
5567 id: *buffer_id,
5568 path: state.buffer.file_path().map(|p| p.to_path_buf()),
5569 modified: state.buffer.is_modified(),
5570 length: state.buffer.len(),
5571 is_virtual,
5572 view_mode: view_mode.to_string(),
5573 is_composing_in_any_split,
5574 compose_width,
5575 language: state.language.clone(),
5576 is_preview,
5577 splits,
5578 };
5579 snapshot.buffers.insert(*buffer_id, buffer_info);
5580
5581 let diff = {
5582 let diff = state.buffer.diff_since_saved();
5583 BufferSavedDiff {
5584 equal: diff.equal,
5585 byte_ranges: diff.byte_ranges.clone(),
5586 }
5587 };
5588 snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
5589
5590 let is_hidden = self
5599 .buffer_metadata
5600 .get(buffer_id)
5601 .is_some_and(|m| m.hidden_from_tabs);
5602 let source_split = vs_ref.iter().find(|(split_id, vs)| {
5603 vs.keyed_states.contains_key(buffer_id)
5604 && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
5605 });
5606 let cursor_pos = source_split
5607 .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
5608 .map(|bs| bs.cursors.primary().position)
5609 .unwrap_or(0);
5610 tracing::trace!(
5611 "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
5612 buffer_id,
5613 cursor_pos,
5614 source_split.map(|(id, _)| *id),
5615 );
5616 snapshot
5617 .buffer_cursor_positions
5618 .insert(*buffer_id, cursor_pos);
5619
5620 if !state.text_properties.is_empty() {
5622 snapshot
5623 .buffer_text_properties
5624 .insert(*buffer_id, state.text_properties.all().to_vec());
5625 }
5626 }
5627
5628 let active_buf_id = snapshot.active_buffer_id;
5630 let active_split_id = self
5631 .buffers
5632 .split_manager()
5633 .map(|m| m.active_split())
5634 .expect("active window must have a populated split layout");
5635 self.buffers
5636 .with_all_mut(|buffers_mut, mgr, vs_map| {
5637 let _ = mgr; if let Some(active_vs) = vs_map.get(&active_split_id) {
5639 let active_cursors = &active_vs.cursors;
5641 let primary = active_cursors.primary();
5642 let primary_position = primary.position;
5643 let primary_selection = primary.selection_range();
5644
5645 snapshot.primary_cursor = Some(CursorInfo {
5646 position: primary_position,
5647 selection: primary_selection.clone(),
5648 });
5649
5650 snapshot.all_cursors = active_cursors
5651 .iter()
5652 .map(|(_, cursor)| CursorInfo {
5653 position: cursor.position,
5654 selection: cursor.selection_range(),
5655 })
5656 .collect();
5657
5658 if let Some(range) = primary_selection {
5660 if let Some(active_state) = buffers_mut.get_mut(&active_buf_id) {
5661 snapshot.selected_text =
5662 Some(active_state.get_text_range(range.start, range.end));
5663 }
5664 }
5665
5666 let top_line = buffers_mut.get(&active_buf_id).and_then(|state| {
5668 if state.buffer.line_count().is_some() {
5669 Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
5670 } else {
5671 None
5672 }
5673 });
5674 snapshot.viewport = Some(ViewportInfo {
5675 top_byte: active_vs.viewport.top_byte,
5676 top_line,
5677 left_column: active_vs.viewport.left_column,
5678 width: active_vs.viewport.width,
5679 height: active_vs.viewport.height,
5680 });
5681 } else {
5682 snapshot.primary_cursor = None;
5683 snapshot.all_cursors.clear();
5684 snapshot.viewport = None;
5685 snapshot.selected_text = None;
5686 }
5687
5688 snapshot.splits.clear();
5690 for (leaf_id, vs) in vs_map.iter() {
5691 let buf_id = vs.active_buffer;
5692 let top_line = buffers_mut.get(&buf_id).and_then(|state| {
5693 if state.buffer.line_count().is_some() {
5694 Some(state.buffer.get_line_number(vs.viewport.top_byte))
5695 } else {
5696 None
5697 }
5698 });
5699 snapshot.splits.push(fresh_core::api::SplitSnapshot {
5700 split_id: leaf_id.0 .0,
5701 buffer_id: buf_id,
5702 viewport: ViewportInfo {
5703 top_byte: vs.viewport.top_byte,
5704 top_line,
5705 left_column: vs.viewport.left_column,
5706 width: vs.viewport.width,
5707 height: vs.viewport.height,
5708 },
5709 });
5710 }
5711 })
5712 .expect("active window must have a populated split layout");
5713
5714 snapshot.active_session_plugin_states = self.plugin_state.clone();
5720 snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
5725 snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
5726
5727 snapshot.editor_mode = self.editor_mode.clone();
5729
5730 let active_split_id_u64 = active_split_id.0 .0;
5735 let split_changed = snapshot.plugin_view_states_split != active_split_id_u64;
5736 if split_changed {
5737 snapshot.plugin_view_states.clear();
5738 snapshot.plugin_view_states_split = active_split_id_u64;
5739 }
5740
5741 {
5743 let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
5744 snapshot
5745 .plugin_view_states
5746 .retain(|bid, _| open_bids.contains(bid));
5747 }
5748
5749 if let Some(vs_map) = self.buffers.split_view_states() {
5751 if let Some(active_vs) = vs_map.get(&active_split_id) {
5752 for (buffer_id, buf_state) in &active_vs.keyed_states {
5753 if !buf_state.plugin_state.is_empty() {
5754 let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
5755 for (key, value) in &buf_state.plugin_state {
5756 entry.entry(key.clone()).or_insert_with(|| value.clone());
5757 }
5758 }
5759 }
5760 }
5761 }
5762 }
5763}