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
27fn buffer_line_byte_offset(
32 content: &str,
33 buffer_len: usize,
34 line: usize,
35 want_end: bool,
36) -> Option<usize> {
37 if !want_end && line == 0 {
38 return Some(0);
39 }
40 let mut current_line = 0usize;
41 for (byte_idx, c) in content.char_indices() {
42 if c == '\n' {
43 if want_end && current_line == line {
44 return Some(byte_idx);
45 }
46 current_line += 1;
47 if !want_end && current_line == line {
48 return Some(byte_idx + 1);
49 }
50 }
51 }
52 if want_end && current_line == line {
53 Some(buffer_len)
54 } else {
55 None
56 }
57}
58
59fn find_scrollable_widget_key(spec: &fresh_core::api::WidgetSpec) -> Option<String> {
67 use fresh_core::api::WidgetSpec;
68 match spec {
69 WidgetSpec::Tree { key: Some(k), .. } | WidgetSpec::List { key: Some(k), .. }
70 if !k.is_empty() =>
71 {
72 return Some(k.clone());
73 }
74 _ => {}
75 }
76 spec.children().find_map(find_scrollable_widget_key)
77}
78
79fn collect_visible_tree_indices(
80 nodes: &[fresh_core::api::TreeNode],
81 item_keys: &[String],
82 expanded: &std::collections::HashSet<String>,
83) -> Vec<usize> {
84 let mut ancestor_open: Vec<bool> = Vec::new();
85 let mut visible: Vec<usize> = Vec::with_capacity(nodes.len());
86 for (i, node) in nodes.iter().enumerate() {
87 let depth = node.depth as usize;
88 ancestor_open.truncate(depth);
89 if ancestor_open.iter().all(|open| *open) {
90 visible.push(i);
91 }
92 let key = item_keys.get(i).cloned().unwrap_or_default();
93 let is_open = if node.has_children {
94 !key.is_empty() && expanded.contains(&key)
95 } else {
96 true
97 };
98 ancestor_open.push(is_open);
99 }
100 visible
101}
102
103impl Editor {
104 #[cfg(feature = "plugins")]
113 pub(super) fn update_plugin_state_snapshot(&mut self) {
114 let Some(snapshot_handle) = self.plugin_manager.read().unwrap().state_snapshot_handle()
115 else {
116 return;
117 };
118 let mut snapshot = snapshot_handle.write().unwrap();
119
120 self.active_window_mut()
121 .populate_plugin_state_snapshot(&mut snapshot);
122
123 snapshot.clipboard = self.clipboard.get_internal().to_string();
127 snapshot.working_dir = self.working_dir.clone();
128
129 snapshot.terminal_width = self.terminal_width;
133 snapshot.terminal_height = self.terminal_height;
134
135 snapshot.authority_label = self.authority.display_label.clone();
142
143 snapshot.workspace_trust_level =
146 self.authority.workspace_trust.level().as_str().to_string();
147 snapshot.env_active = self.authority.env_provider.is_active();
148
149 let mut session_infos: Vec<fresh_core::api::WindowInfo> = self
155 .windows
156 .values()
157 .map(|s| {
158 let slot = s.plugin_state.get("orchestrator");
159 let project_path = slot
160 .and_then(|m| m.get("project_path"))
161 .and_then(|v| v.as_str())
162 .map(std::path::PathBuf::from);
163 let shared_worktree = slot
164 .and_then(|m| m.get("shared_worktree"))
165 .and_then(|v| v.as_bool())
166 .unwrap_or(false);
167 fresh_core::api::WindowInfo {
168 id: s.id,
169 label: s.label.clone(),
170 root: s.root.clone(),
171 project_path,
172 shared_worktree,
173 }
174 })
175 .collect();
176 session_infos.sort_by_key(|s| s.id.0);
177 snapshot.windows = session_infos;
178 snapshot.active_window_id = self.active_window;
179
180 if !Arc::ptr_eq(&self.config, &self.config_snapshot_anchor) {
189 let json = serde_json::to_value(&*self.config).unwrap_or(serde_json::Value::Null);
190 self.config_cached_json = Arc::new(json);
191 self.config_snapshot_anchor = Arc::clone(&self.config);
192 }
193 snapshot.config = Arc::clone(&self.config_cached_json);
194
195 snapshot.user_config = Arc::clone(&self.user_config_raw);
198
199 for (plugin_name, state_map) in &self.plugin_global_state {
202 let entry = snapshot
203 .plugin_global_states
204 .entry(plugin_name.clone())
205 .or_default();
206 for (key, value) in state_map {
207 entry.entry(key.clone()).or_insert_with(|| value.clone());
208 }
209 }
210 }
211
212 pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
214 match command {
215 PluginCommand::InsertText {
217 buffer_id,
218 position,
219 text,
220 } => {
221 self.handle_insert_text(buffer_id, position, text);
222 }
223 PluginCommand::DeleteRange { buffer_id, range } => {
224 self.handle_delete_range(buffer_id, range);
225 }
226 PluginCommand::InsertAtCursor { text } => {
227 self.handle_insert_at_cursor(text);
228 }
229 PluginCommand::DeleteSelection => {
230 self.handle_delete_selection();
231 }
232
233 PluginCommand::AddOverlay {
235 buffer_id,
236 namespace,
237 range,
238 options,
239 } => {
240 self.handle_add_overlay(buffer_id, namespace, range, options);
241 }
242 PluginCommand::RemoveOverlay { buffer_id, handle } => {
243 self.handle_remove_overlay(buffer_id, handle);
244 }
245 PluginCommand::ClearAllOverlays { buffer_id } => {
246 self.handle_clear_all_overlays(buffer_id);
247 }
248 PluginCommand::ClearNamespace {
249 buffer_id,
250 namespace,
251 } => {
252 self.handle_clear_namespace(buffer_id, namespace);
253 }
254 PluginCommand::ClearOverlaysInRange {
255 buffer_id,
256 start,
257 end,
258 } => {
259 self.handle_clear_overlays_in_range(buffer_id, start, end);
260 }
261
262 PluginCommand::AddVirtualText {
264 buffer_id,
265 virtual_text_id,
266 position,
267 text,
268 color,
269 use_bg,
270 before,
271 } => {
272 self.handle_add_virtual_text(
273 buffer_id,
274 virtual_text_id,
275 position,
276 text,
277 color,
278 use_bg,
279 before,
280 );
281 }
282 PluginCommand::AddVirtualTextStyled {
283 buffer_id,
284 virtual_text_id,
285 position,
286 text,
287 fg,
288 bg,
289 bold,
290 italic,
291 before,
292 } => {
293 self.handle_add_virtual_text_styled(
294 buffer_id,
295 virtual_text_id,
296 position,
297 text,
298 fg,
299 bg,
300 bold,
301 italic,
302 before,
303 );
304 }
305 PluginCommand::RemoveVirtualText {
306 buffer_id,
307 virtual_text_id,
308 } => {
309 self.handle_remove_virtual_text(buffer_id, virtual_text_id);
310 }
311 PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
312 self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
313 }
314 PluginCommand::ClearVirtualTexts { buffer_id } => {
315 self.handle_clear_virtual_texts(buffer_id);
316 }
317 PluginCommand::AddVirtualLine {
318 buffer_id,
319 position,
320 text,
321 fg_color,
322 bg_color,
323 above,
324 namespace,
325 priority,
326 gutter_glyph,
327 gutter_color,
328 text_overlays,
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 text_overlays,
342 );
343 }
344 PluginCommand::ClearVirtualTextNamespace {
345 buffer_id,
346 namespace,
347 } => {
348 self.handle_clear_virtual_text_namespace(buffer_id, namespace);
349 }
350
351 PluginCommand::AddConceal {
353 buffer_id,
354 namespace,
355 start,
356 end,
357 replacement,
358 } => {
359 self.handle_add_conceal(buffer_id, namespace, start, end, replacement);
360 }
361 PluginCommand::ClearConcealNamespace {
362 buffer_id,
363 namespace,
364 } => {
365 self.handle_clear_conceal_namespace(buffer_id, namespace);
366 }
367 PluginCommand::ClearConcealsInRange {
368 buffer_id,
369 start,
370 end,
371 } => {
372 self.handle_clear_conceals_in_range(buffer_id, start, end);
373 }
374
375 PluginCommand::AddFold {
376 buffer_id,
377 start,
378 end,
379 placeholder,
380 } => {
381 self.handle_add_fold(buffer_id, start, end, placeholder);
382 }
383 PluginCommand::ClearFolds { buffer_id } => {
384 self.handle_clear_folds(buffer_id);
385 }
386 PluginCommand::SetFoldingRanges { buffer_id, ranges } => {
387 self.handle_set_folding_ranges(buffer_id, ranges);
388 }
389
390 PluginCommand::AddSoftBreak {
392 buffer_id,
393 namespace,
394 position,
395 indent,
396 } => {
397 self.handle_add_soft_break(buffer_id, namespace, position, indent);
398 }
399 PluginCommand::ClearSoftBreakNamespace {
400 buffer_id,
401 namespace,
402 } => {
403 self.handle_clear_soft_break_namespace(buffer_id, namespace);
404 }
405 PluginCommand::ClearSoftBreaksInRange {
406 buffer_id,
407 start,
408 end,
409 } => {
410 self.handle_clear_soft_breaks_in_range(buffer_id, start, end);
411 }
412
413 PluginCommand::AddMenuItem {
415 menu_label,
416 item,
417 position,
418 } => {
419 self.handle_add_menu_item(menu_label, item, position);
420 }
421 PluginCommand::AddMenu { menu, position } => {
422 self.handle_add_menu(menu, position);
423 }
424 PluginCommand::RemoveMenuItem {
425 menu_label,
426 item_label,
427 } => {
428 self.handle_remove_menu_item(menu_label, item_label);
429 }
430 PluginCommand::RemoveMenu { menu_label } => {
431 self.handle_remove_menu(menu_label);
432 }
433
434 PluginCommand::FocusSplit { split_id } => {
436 self.handle_focus_split(split_id);
437 }
438 PluginCommand::SetSplitBuffer {
439 split_id,
440 buffer_id,
441 } => {
442 self.handle_set_split_buffer(split_id, buffer_id);
443 }
444 PluginCommand::SetSplitScroll { split_id, top_byte } => {
445 self.handle_set_split_scroll(split_id, top_byte);
446 }
447 PluginCommand::RequestHighlights {
448 buffer_id,
449 range,
450 request_id,
451 } => {
452 self.handle_request_highlights(buffer_id, range, request_id);
453 }
454 PluginCommand::CloseSplit { split_id } => {
455 self.handle_close_split(split_id);
456 }
457 PluginCommand::SetSplitRatio { split_id, ratio } => {
458 self.handle_set_split_ratio(split_id, ratio);
459 }
460 PluginCommand::SetSplitLabel { split_id, label } => {
461 self.windows
462 .get_mut(&self.active_window)
463 .and_then(|w| w.split_manager_mut())
464 .expect("active window must have a populated split layout")
465 .set_label(LeafId(split_id), label);
466 }
467 PluginCommand::ClearSplitLabel { split_id } => {
468 self.windows
469 .get_mut(&self.active_window)
470 .and_then(|w| w.split_manager_mut())
471 .expect("active window must have a populated split layout")
472 .clear_label(split_id);
473 }
474 PluginCommand::GetSplitByLabel { label, request_id } => {
475 self.handle_get_split_by_label(label, request_id);
476 }
477 PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
478 self.handle_distribute_splits_evenly();
479 }
480 PluginCommand::SetBufferCursor {
481 buffer_id,
482 position,
483 } => {
484 self.handle_set_buffer_cursor(buffer_id, position);
485 }
486 PluginCommand::SetBufferShowCursors { buffer_id, show } => {
487 self.handle_set_buffer_show_cursors(buffer_id, show);
488 }
489
490 PluginCommand::SetLayoutHints {
492 buffer_id,
493 split_id,
494 range: _,
495 hints,
496 } => {
497 self.handle_set_layout_hints(buffer_id, split_id, hints);
498 }
499 PluginCommand::SetLineNumbers { buffer_id, enabled } => {
500 self.handle_set_line_numbers(buffer_id, enabled);
501 }
502 PluginCommand::SetViewMode { buffer_id, mode } => {
503 self.handle_set_view_mode(buffer_id, &mode);
504 }
505 PluginCommand::SetLineWrap {
506 buffer_id,
507 split_id,
508 enabled,
509 } => {
510 self.handle_set_line_wrap(buffer_id, split_id, enabled);
511 }
512 PluginCommand::SubmitViewTransform {
513 buffer_id,
514 split_id,
515 payload,
516 } => {
517 self.handle_submit_view_transform(buffer_id, split_id, payload);
518 }
519 PluginCommand::ClearViewTransform {
520 buffer_id: _,
521 split_id,
522 } => {
523 self.handle_clear_view_transform(split_id);
524 }
525 PluginCommand::SetViewState {
526 buffer_id,
527 key,
528 value,
529 } => {
530 self.handle_set_view_state(buffer_id, key, value);
531 }
532 PluginCommand::SetGlobalState {
533 plugin_name,
534 key,
535 value,
536 } => {
537 self.handle_set_global_state(plugin_name, key, value);
538 }
539 PluginCommand::SetWindowState {
540 plugin_name,
541 key,
542 value,
543 } => {
544 self.handle_set_session_state(plugin_name, key, value);
545 }
546 PluginCommand::RefreshLines { buffer_id } => {
547 self.handle_refresh_lines(buffer_id);
548 }
549 PluginCommand::RefreshAllLines => {
550 self.handle_refresh_all_lines();
551 }
552 PluginCommand::HookCompleted { .. } => {
553 }
555 PluginCommand::SetLineIndicator {
556 buffer_id,
557 line,
558 namespace,
559 symbol,
560 color,
561 priority,
562 } => {
563 self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
564 }
565 PluginCommand::SetLineIndicators {
566 buffer_id,
567 lines,
568 namespace,
569 symbol,
570 color,
571 priority,
572 } => {
573 self.handle_set_line_indicators(
574 buffer_id, lines, namespace, symbol, color, priority,
575 );
576 }
577 PluginCommand::ClearLineIndicators {
578 buffer_id,
579 namespace,
580 } => {
581 self.handle_clear_line_indicators(buffer_id, namespace);
582 }
583 PluginCommand::SetFileExplorerDecorations {
584 namespace,
585 decorations,
586 } => {
587 self.active_window_mut()
588 .handle_set_file_explorer_decorations(namespace, decorations);
589 }
590 PluginCommand::ClearFileExplorerDecorations { namespace } => {
591 self.active_window_mut()
592 .handle_clear_file_explorer_decorations(&namespace);
593 }
594
595 PluginCommand::SetStatus { message } => {
597 self.handle_set_status(message);
598 }
599 PluginCommand::ApplyTheme { theme_name } => {
600 self.apply_theme(&theme_name);
601 }
602 PluginCommand::OverrideThemeColors { overrides } => {
603 self.handle_override_theme_colors(overrides);
604 }
605 PluginCommand::ReloadConfig => {
606 self.reload_config();
607 }
608 PluginCommand::SetSetting { path, value, .. } => {
609 self.handle_set_setting(path, value);
610 }
611 PluginCommand::AddPluginConfigField {
612 plugin_name,
613 field_name,
614 field_schema,
615 } => {
616 self.handle_add_plugin_config_field(plugin_name, field_name, field_schema);
617 }
618 PluginCommand::ReloadThemes { apply_theme } => {
619 self.reload_themes();
620 if let Some(theme_name) = apply_theme {
621 self.apply_theme(&theme_name);
622 }
623 }
624 PluginCommand::RegisterGrammar {
625 language,
626 grammar_path,
627 extensions,
628 } => {
629 self.handle_register_grammar(language, grammar_path, extensions);
630 }
631 PluginCommand::RegisterLanguageConfig { language, config } => {
632 self.handle_register_language_config(language, config);
633 }
634 PluginCommand::RegisterLspServer { language, config } => {
635 self.handle_register_lsp_server(language, config);
636 }
637 PluginCommand::ReloadGrammars { callback_id } => {
638 self.handle_reload_grammars(callback_id);
639 }
640 PluginCommand::StartPrompt {
641 label,
642 prompt_type,
643 floating_overlay,
644 } => {
645 self.handle_start_prompt(label, prompt_type, floating_overlay);
646 }
647 PluginCommand::StartPromptWithInitial {
648 label,
649 prompt_type,
650 initial_value,
651 floating_overlay,
652 } => {
653 self.handle_start_prompt_with_initial(
654 label,
655 prompt_type,
656 initial_value,
657 floating_overlay,
658 );
659 }
660 PluginCommand::StartPromptAsync {
661 label,
662 initial_value,
663 callback_id,
664 } => {
665 self.handle_start_prompt_async(label, initial_value, callback_id);
666 }
667 PluginCommand::AwaitNextKey { callback_id } => {
668 self.handle_await_next_key(callback_id);
669 }
670 PluginCommand::SetKeyCaptureActive { active } => {
671 self.active_window_mut().key_capture_active = active;
672 if !active {
673 self.active_window_mut().pending_key_capture_buffer.clear();
677 }
678 }
679 PluginCommand::SetPromptSuggestions { suggestions } => {
680 self.handle_set_prompt_suggestions(suggestions);
681 }
682 PluginCommand::SetPromptInputSync { sync } => {
683 if let Some(prompt) = &mut self.active_window_mut().prompt {
684 prompt.sync_input_on_navigate = sync;
685 }
686 }
687 PluginCommand::SetPromptTitle { title } => {
688 if let Some(prompt) = &mut self.active_window_mut().prompt {
689 prompt.title = title;
690 }
691 }
692 PluginCommand::SetPromptFooter { footer } => {
693 if let Some(prompt) = &mut self.active_window_mut().prompt {
694 prompt.footer = footer;
695 }
696 }
697 PluginCommand::SetPromptSelectedIndex { index } => {
698 if let Some(prompt) = &mut self.active_window_mut().prompt {
699 let len = prompt.suggestions.len();
700 if len > 0 {
701 let clamped = (index as usize).min(len - 1);
702 prompt.selected_suggestion = Some(clamped);
703 }
704 }
705 }
706
707 PluginCommand::CreateWindow { root, label } => {
710 if !root.is_absolute() {
711 tracing::warn!(
712 "CreateWindow rejected: root must be absolute, got {:?}",
713 root
714 );
715 } else {
716 let _ = self.create_window_at(root, label);
717 }
718 }
719 PluginCommand::CreateWindowWithTerminal {
720 root,
721 label,
722 cwd,
723 command,
724 title,
725 request_id,
726 } => {
727 self.handle_create_window_with_terminal(
728 root, label, cwd, command, title, request_id,
729 );
730 }
731 PluginCommand::SetActiveWindow { id } => {
732 self.set_active_window(id);
733 }
734 PluginCommand::CloseWindow { id } => {
735 let _ = self.close_window(id);
736 }
737 PluginCommand::PrewarmWindow { id } => {
738 self.prewarm_window(id);
739 }
740
741 PluginCommand::WatchPath {
743 path,
744 recursive,
745 request_id,
746 } => {
747 let result = if let Some(ref bridge) = self.async_bridge {
748 self.file_watcher_manager.watch(bridge, &path, recursive)
749 } else {
750 Err(
751 "watchPath: no async bridge — file watching is unavailable in this build"
752 .to_string(),
753 )
754 };
755 self.last_watch_response_for_test = Some((request_id, result.clone()));
756 self.send_plugin_response(fresh_core::api::PluginResponse::WatchPathRegistered {
757 request_id,
758 result,
759 });
760 }
761 PluginCommand::UnwatchPath { handle } => {
762 self.file_watcher_manager.unwatch(handle);
763 }
764
765 PluginCommand::PreviewWindowInRect { id } => {
766 self.preview_window_id = match id {
770 Some(sid) if sid != self.active_window && self.windows.contains_key(&sid) => {
771 Some(sid)
772 }
773 _ => None,
774 };
775 }
776
777 PluginCommand::RegisterCommand { command } => {
779 self.handle_register_command(command);
780 }
781 PluginCommand::RegisterStatusBarElement {
782 plugin_name,
783 token_name,
784 title,
785 } => {
786 if let Err(e) = self.register_status_bar_element(&plugin_name, &token_name, &title)
787 {
788 tracing::warn!("Failed to register statusbar element: {}", e);
789 }
790 }
791 PluginCommand::SetStatusBarValue {
792 buffer_id,
793 key,
794 value,
795 } => {
796 if let Err(e) =
797 self.set_status_bar_value(fresh_core::BufferId(buffer_id as usize), &key, value)
798 {
799 tracing::warn!("Failed to set statusbar value: {}", e);
800 }
801 }
802 PluginCommand::UnregisterCommand { name } => {
803 self.handle_unregister_command(name);
804 }
805 PluginCommand::DefineMode {
806 name,
807 bindings,
808 read_only,
809 allow_text_input,
810 inherit_normal_bindings,
811 plugin_name,
812 } => {
813 self.handle_define_mode(
814 name,
815 bindings,
816 read_only,
817 allow_text_input,
818 inherit_normal_bindings,
819 plugin_name,
820 );
821 }
822
823 PluginCommand::OpenFileInBackground { path, window_id } => {
825 let route_to_inactive = match window_id {
826 Some(id) if id != self.active_window && self.windows.contains_key(&id) => {
827 Some(id)
828 }
829 _ => None,
830 };
831 if let Some(target) = route_to_inactive {
832 self.handle_open_file_in_inactive_session(target, path);
833 } else {
834 self.handle_open_file_in_background(path);
835 }
836 }
837 PluginCommand::OpenFileAtLocation { path, line, column } => {
838 return self.handle_open_file_at_location(path, line, column);
839 }
840 PluginCommand::OpenFileInSplit {
841 split_id,
842 path,
843 line,
844 column,
845 } => {
846 return self.handle_open_file_in_split(split_id, path, line, column);
847 }
848 PluginCommand::ShowBuffer { buffer_id } => {
849 self.handle_show_buffer(buffer_id);
850 }
851 PluginCommand::CloseBuffer { buffer_id } => {
852 self.handle_close_buffer(buffer_id);
853 }
854 PluginCommand::CloseOtherBuffersInSplit {
855 buffer_id,
856 split_id,
857 } => {
858 self.handle_close_other_buffers_in_split(buffer_id, split_id);
859 }
860 PluginCommand::CloseAllBuffersInSplit { split_id } => {
861 self.handle_close_all_buffers_in_split(split_id);
862 }
863 PluginCommand::CloseBuffersToRightInSplit {
864 buffer_id,
865 split_id,
866 } => {
867 self.handle_close_buffers_to_right_in_split(buffer_id, split_id);
868 }
869 PluginCommand::CloseBuffersToLeftInSplit {
870 buffer_id,
871 split_id,
872 } => {
873 self.handle_close_buffers_to_left_in_split(buffer_id, split_id);
874 }
875
876 PluginCommand::MoveTabLeft => {
877 self.handle_move_tab_left();
878 }
879 PluginCommand::MoveTabRight => {
880 self.handle_move_tab_right();
881 }
882
883 PluginCommand::StartAnimationArea { id, rect, kind } => {
885 self.handle_start_animation_area(id, rect, kind);
886 }
887 PluginCommand::StartAnimationVirtualBuffer {
888 id,
889 buffer_id,
890 kind,
891 } => {
892 self.handle_start_animation_virtual_buffer(id, buffer_id, kind);
893 }
894 PluginCommand::CancelAnimation { id } => {
895 self.active_window_mut()
896 .animations
897 .cancel(crate::view::animation::AnimationId::from_raw(id));
898 }
899
900 PluginCommand::SendLspRequest {
902 language,
903 method,
904 params,
905 request_id,
906 } => {
907 self.handle_send_lsp_request(language, method, params, request_id);
908 }
909
910 PluginCommand::SetClipboard { text } => {
912 self.handle_set_clipboard(text);
913 }
914
915 PluginCommand::SpawnProcess {
917 command,
918 args,
919 cwd,
920 stdout_to,
921 callback_id,
922 } => {
923 self.handle_spawn_process(command, args, cwd, stdout_to, callback_id);
924 }
925
926 PluginCommand::SpawnHostProcess {
927 command,
928 args,
929 cwd,
930 callback_id,
931 } => {
932 self.handle_spawn_host_process(command, args, cwd, callback_id);
933 }
934
935 PluginCommand::KillHostProcess { process_id } => {
936 self.handle_kill_host_process(process_id);
937 }
938
939 PluginCommand::SetAuthority { payload } => {
940 self.handle_set_authority(payload);
941 }
942
943 PluginCommand::ClearAuthority => {
944 tracing::info!("Plugin cleared authority; restoring local");
945 self.clear_authority();
946 }
947
948 PluginCommand::SetEnv { snippet, dir } => {
949 use crate::services::workspace_trust::TrustLevel;
953 if self.authority.workspace_trust.level() == TrustLevel::Trusted {
954 self.authority
955 .env_provider
956 .set(snippet, dir.map(std::path::PathBuf::from));
957 self.request_restart(self.working_dir.clone());
959 } else {
960 self.active_window_mut().status_message =
961 Some("Workspace not trusted — cannot activate environment".to_string());
962 }
963 }
964
965 PluginCommand::ClearEnv => {
966 let was_active = self.authority.env_provider.is_active();
967 self.authority.env_provider.clear();
968 if was_active {
969 self.request_restart(self.working_dir.clone());
970 }
971 }
972
973 PluginCommand::SetRemoteIndicatorState { state } => {
974 self.handle_set_remote_indicator_state(state);
975 }
976
977 PluginCommand::ClearRemoteIndicatorState => {
978 self.remote_indicator_override = None;
979 }
980
981 PluginCommand::SpawnProcessWait {
982 process_id,
983 callback_id,
984 } => {
985 self.handle_spawn_process_wait(process_id, callback_id);
986 }
987
988 PluginCommand::Delay {
989 callback_id,
990 duration_ms,
991 } => {
992 self.handle_delay(callback_id, duration_ms);
993 }
994
995 PluginCommand::HttpFetch {
996 url,
997 target_path,
998 callback_id,
999 } => {
1000 self.handle_http_fetch(url, target_path, callback_id);
1001 }
1002
1003 PluginCommand::SpawnBackgroundProcess {
1004 process_id,
1005 command,
1006 args,
1007 cwd,
1008 callback_id,
1009 } => {
1010 self.handle_spawn_background_process(process_id, command, args, cwd, callback_id);
1011 }
1012
1013 PluginCommand::KillBackgroundProcess { process_id } => {
1014 self.handle_kill_background_process(process_id);
1015 }
1016
1017 PluginCommand::CreateVirtualBuffer {
1019 name,
1020 mode,
1021 read_only,
1022 } => {
1023 self.handle_create_virtual_buffer(name, mode, read_only);
1024 }
1025 PluginCommand::CreateVirtualBufferWithContent {
1026 name,
1027 mode,
1028 read_only,
1029 entries,
1030 show_line_numbers,
1031 show_cursors,
1032 editing_disabled,
1033 hidden_from_tabs,
1034 request_id,
1035 } => {
1036 self.handle_create_virtual_buffer_with_content(
1037 name,
1038 mode,
1039 read_only,
1040 entries,
1041 show_line_numbers,
1042 show_cursors,
1043 editing_disabled,
1044 hidden_from_tabs,
1045 request_id,
1046 );
1047 }
1048 PluginCommand::CreateVirtualBufferInSplit {
1049 name,
1050 mode,
1051 read_only,
1052 entries,
1053 ratio,
1054 direction,
1055 panel_id,
1056 show_line_numbers,
1057 show_cursors,
1058 editing_disabled,
1059 line_wrap,
1060 before,
1061 role,
1062 request_id,
1063 } => {
1064 self.handle_create_virtual_buffer_in_split(
1065 name,
1066 mode,
1067 read_only,
1068 entries,
1069 ratio,
1070 direction,
1071 panel_id,
1072 show_line_numbers,
1073 show_cursors,
1074 editing_disabled,
1075 line_wrap,
1076 before,
1077 role,
1078 request_id,
1079 );
1080 }
1081 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
1082 self.handle_set_virtual_buffer_content(buffer_id, entries);
1083 }
1084 PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
1085 self.handle_get_text_properties_at_cursor(buffer_id);
1086 }
1087 PluginCommand::CreateVirtualBufferInExistingSplit {
1088 name,
1089 mode,
1090 read_only,
1091 entries,
1092 split_id,
1093 show_line_numbers,
1094 show_cursors,
1095 editing_disabled,
1096 line_wrap,
1097 request_id,
1098 } => {
1099 self.handle_create_virtual_buffer_in_existing_split(
1100 name,
1101 mode,
1102 read_only,
1103 entries,
1104 split_id,
1105 show_line_numbers,
1106 show_cursors,
1107 editing_disabled,
1108 line_wrap,
1109 request_id,
1110 );
1111 }
1112
1113 PluginCommand::SetContext { name, active } => {
1115 self.handle_set_context(name, active);
1116 }
1117
1118 PluginCommand::SetReviewDiffHunks { hunks } => {
1120 self.active_window_mut().review_hunks = hunks;
1121 tracing::debug!(
1122 "Set {} review hunks",
1123 self.active_window_mut().review_hunks.len()
1124 );
1125 }
1126
1127 PluginCommand::ExecuteAction { action_name } => {
1129 self.handle_execute_action(action_name);
1130 }
1131 PluginCommand::ExecuteActions { actions } => {
1132 self.handle_execute_actions(actions);
1133 }
1134 PluginCommand::GetBufferText {
1135 buffer_id,
1136 start,
1137 end,
1138 request_id,
1139 } => {
1140 self.handle_get_buffer_text(buffer_id, start, end, request_id);
1141 }
1142 PluginCommand::GetLineStartPosition {
1143 buffer_id,
1144 line,
1145 request_id,
1146 } => {
1147 self.handle_get_line_start_position(buffer_id, line, request_id);
1148 }
1149 PluginCommand::GetLineEndPosition {
1150 buffer_id,
1151 line,
1152 request_id,
1153 } => {
1154 self.handle_get_line_end_position(buffer_id, line, request_id);
1155 }
1156 PluginCommand::GetBufferLineCount {
1157 buffer_id,
1158 request_id,
1159 } => {
1160 self.handle_get_buffer_line_count(buffer_id, request_id);
1161 }
1162 PluginCommand::OpenFileStreaming { path, request_id } => {
1163 self.handle_open_file_streaming(path, request_id);
1164 }
1165 PluginCommand::RefreshBufferFromDisk {
1166 buffer_id,
1167 request_id,
1168 } => {
1169 self.handle_refresh_buffer_from_disk(buffer_id, request_id);
1170 }
1171 PluginCommand::SetBufferGroupPanelBuffer {
1172 group_id,
1173 panel_name,
1174 buffer_id,
1175 request_id,
1176 } => {
1177 self.handle_set_buffer_group_panel_buffer(
1178 group_id, panel_name, buffer_id, request_id,
1179 );
1180 }
1181 PluginCommand::ScrollToLineCenter {
1182 split_id,
1183 buffer_id,
1184 line,
1185 } => {
1186 self.handle_scroll_to_line_center(split_id, buffer_id, line);
1187 }
1188 PluginCommand::ScrollBufferToLine { buffer_id, line } => {
1189 self.handle_scroll_buffer_to_line(buffer_id, line);
1190 }
1191 PluginCommand::SetEditorMode { mode } => {
1192 self.handle_set_editor_mode(mode);
1193 }
1194
1195 PluginCommand::ShowActionPopup {
1197 popup_id,
1198 title,
1199 message,
1200 actions,
1201 } => {
1202 self.handle_show_action_popup(popup_id, title, message, actions);
1203 }
1204
1205 PluginCommand::SetLspMenuContributions {
1206 plugin_id,
1207 language,
1208 items,
1209 } => {
1210 self.handle_set_lsp_menu_contributions(plugin_id, language, items);
1211 }
1212
1213 PluginCommand::DisableLspForLanguage { language } => {
1214 self.handle_disable_lsp_for_language(language);
1215 }
1216
1217 PluginCommand::RestartLspForLanguage { language } => {
1218 self.handle_restart_lsp_for_language(language);
1219 }
1220
1221 PluginCommand::SetLspRootUri { language, uri } => {
1222 self.handle_set_lsp_root_uri(language, uri);
1223 }
1224
1225 PluginCommand::CreateScrollSyncGroup {
1227 group_id,
1228 left_split,
1229 right_split,
1230 } => {
1231 self.handle_create_scroll_sync_group(group_id, left_split, right_split);
1232 }
1233 PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
1234 self.handle_set_scroll_sync_anchors(group_id, anchors);
1235 }
1236 PluginCommand::RemoveScrollSyncGroup { group_id } => {
1237 self.handle_remove_scroll_sync_group(group_id);
1238 }
1239
1240 PluginCommand::CreateCompositeBuffer {
1242 name,
1243 mode,
1244 layout,
1245 sources,
1246 hunks,
1247 initial_focus_hunk,
1248 request_id,
1249 } => {
1250 self.handle_create_composite_buffer(
1251 name,
1252 mode,
1253 layout,
1254 sources,
1255 hunks,
1256 initial_focus_hunk,
1257 request_id,
1258 );
1259 }
1260 PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
1261 self.handle_update_composite_alignment(buffer_id, hunks);
1262 }
1263 PluginCommand::CloseCompositeBuffer { buffer_id } => {
1264 self.active_window_mut().close_composite_buffer(buffer_id);
1265 }
1266 PluginCommand::FlushLayout => {
1267 self.flush_layout();
1268 }
1269 PluginCommand::CompositeNextHunk { buffer_id } => {
1270 let split_id = self
1271 .windows
1272 .get(&self.active_window)
1273 .and_then(|w| w.buffers.splits())
1274 .map(|(mgr, _)| mgr)
1275 .expect("active window must have a populated split layout")
1276 .active_split();
1277 self.active_window_mut()
1278 .composite_next_hunk(split_id, buffer_id);
1279 }
1280 PluginCommand::CompositePrevHunk { buffer_id } => {
1281 let split_id = self
1282 .windows
1283 .get(&self.active_window)
1284 .and_then(|w| w.buffers.splits())
1285 .map(|(mgr, _)| mgr)
1286 .expect("active window must have a populated split layout")
1287 .active_split();
1288 self.active_window_mut()
1289 .composite_prev_hunk(split_id, buffer_id);
1290 }
1291
1292 PluginCommand::CreateBufferGroup {
1294 name,
1295 mode,
1296 layout_json,
1297 request_id,
1298 } => {
1299 self.handle_create_buffer_group(name, mode, layout_json, request_id);
1300 }
1301 PluginCommand::SetPanelContent {
1302 group_id,
1303 panel_name,
1304 entries,
1305 } => {
1306 self.set_panel_content(group_id, panel_name, entries);
1307 }
1308 PluginCommand::CloseBufferGroup { group_id } => {
1309 self.close_buffer_group(group_id);
1310 }
1311 PluginCommand::FocusPanel {
1312 group_id,
1313 panel_name,
1314 } => {
1315 self.focus_panel(group_id, panel_name);
1316 }
1317
1318 PluginCommand::SaveBufferToPath { buffer_id, path } => {
1320 self.handle_save_buffer_to_path(buffer_id, path);
1321 }
1322
1323 #[cfg(feature = "plugins")]
1325 PluginCommand::LoadPlugin { path, callback_id } => {
1326 self.handle_load_plugin(path, callback_id);
1327 }
1328 #[cfg(feature = "plugins")]
1329 PluginCommand::UnloadPlugin { name, callback_id } => {
1330 self.handle_unload_plugin(name, callback_id);
1331 }
1332 #[cfg(feature = "plugins")]
1333 PluginCommand::ReloadPlugin { name, callback_id } => {
1334 self.handle_reload_plugin(name, callback_id);
1335 }
1336 #[cfg(feature = "plugins")]
1337 PluginCommand::ListPlugins { callback_id } => {
1338 self.handle_list_plugins(callback_id);
1339 }
1340 #[cfg(not(feature = "plugins"))]
1342 PluginCommand::LoadPlugin { .. }
1343 | PluginCommand::UnloadPlugin { .. }
1344 | PluginCommand::ReloadPlugin { .. }
1345 | PluginCommand::ListPlugins { .. } => {
1346 tracing::warn!("Plugin management commands require the 'plugins' feature");
1347 }
1348
1349 PluginCommand::CreateTerminal {
1351 cwd,
1352 direction,
1353 ratio,
1354 focus,
1355 persistent,
1356 window_id,
1357 command,
1358 title,
1359 request_id,
1360 } => {
1361 self.handle_create_terminal(
1362 cwd, direction, ratio, focus, persistent, window_id, command, title, request_id,
1363 );
1364 }
1365
1366 PluginCommand::SendTerminalInput { terminal_id, data } => {
1367 self.handle_send_terminal_input(terminal_id, data);
1368 }
1369
1370 PluginCommand::CloseTerminal { terminal_id } => {
1371 self.handle_close_terminal(terminal_id);
1372 }
1373
1374 PluginCommand::SignalWindow { id, signal } => {
1375 self.handle_signal_window(id, &signal);
1376 }
1377
1378 PluginCommand::GrepProject {
1379 pattern,
1380 fixed_string,
1381 case_sensitive,
1382 max_results,
1383 whole_words,
1384 callback_id,
1385 } => {
1386 self.handle_grep_project(
1387 pattern,
1388 fixed_string,
1389 case_sensitive,
1390 max_results,
1391 whole_words,
1392 callback_id,
1393 );
1394 }
1395
1396 PluginCommand::BeginSearch {
1397 pattern,
1398 fixed_string,
1399 case_sensitive,
1400 max_results,
1401 whole_words,
1402 handle_id,
1403 } => {
1404 self.handle_begin_search(
1405 pattern,
1406 fixed_string,
1407 case_sensitive,
1408 max_results,
1409 whole_words,
1410 handle_id,
1411 );
1412 }
1413
1414 PluginCommand::ReplaceInBuffer {
1415 file_path,
1416 matches,
1417 replacement,
1418 callback_id,
1419 } => {
1420 self.handle_replace_in_buffer(file_path, matches, replacement, callback_id);
1421 }
1422
1423 PluginCommand::MountWidgetPanel {
1424 panel_id,
1425 buffer_id,
1426 spec,
1427 } => {
1428 self.handle_mount_widget_panel(panel_id, buffer_id, spec);
1429 }
1430
1431 PluginCommand::UpdateWidgetPanel { panel_id, spec } => {
1432 self.handle_update_widget_panel(panel_id, spec);
1433 }
1434
1435 PluginCommand::UnmountWidgetPanel { panel_id } => {
1436 self.handle_unmount_widget_panel(panel_id);
1437 }
1438
1439 PluginCommand::WidgetCommand { panel_id, action } => {
1440 self.handle_widget_command(panel_id, action);
1441 }
1442
1443 PluginCommand::WidgetMutate { panel_id, mutation } => {
1444 self.handle_widget_mutate(panel_id, mutation);
1445 }
1446
1447 PluginCommand::MountFloatingWidget {
1448 panel_id,
1449 spec,
1450 width_pct,
1451 height_pct,
1452 } => {
1453 self.handle_mount_floating_widget(panel_id, spec, width_pct, height_pct);
1454 }
1455
1456 PluginCommand::UpdateFloatingWidget { panel_id, spec } => {
1457 self.handle_update_floating_widget(panel_id, spec);
1458 }
1459
1460 PluginCommand::UnmountFloatingWidget { panel_id } => {
1461 self.handle_unmount_floating_widget(panel_id);
1462 }
1463 }
1464 Ok(())
1465 }
1466
1467 fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
1469 if let Some(state) = self
1470 .windows
1471 .get_mut(&self.active_window)
1472 .map(|w| &mut w.buffers)
1473 .expect("active window present")
1474 .get_mut(&buffer_id)
1475 {
1476 match state.buffer.save_to_file(&path) {
1478 Ok(()) => {
1479 if let Err(e) = self.finalize_save(Some(path)) {
1482 tracing::warn!("Failed to finalize save: {}", e);
1483 }
1484 tracing::debug!("Saved buffer {:?} to path", buffer_id);
1485 }
1486 Err(e) => {
1487 self.handle_set_status(format!("Error saving: {}", e));
1488 tracing::error!("Failed to save buffer to path: {}", e);
1489 }
1490 }
1491 } else {
1492 self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
1493 tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
1494 }
1495 }
1496
1497 #[cfg(feature = "plugins")]
1499 fn handle_load_plugin(&mut self, path: std::path::PathBuf, callback_id: JsCallbackId) {
1500 let load_result = self.plugin_manager.read().unwrap().load_plugin(&path);
1501 match load_result {
1502 Ok(()) => {
1503 tracing::info!("Loaded plugin from {:?}", path);
1504 self.plugin_manager
1505 .read()
1506 .unwrap()
1507 .resolve_callback(callback_id, "true".to_string());
1508 }
1509 Err(e) => {
1510 tracing::error!("Failed to load plugin from {:?}: {}", path, e);
1511 self.plugin_manager
1512 .read()
1513 .unwrap()
1514 .reject_callback(callback_id, format!("{}", e));
1515 }
1516 }
1517 }
1518
1519 #[cfg(feature = "plugins")]
1521 fn handle_unload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1522 let result = self.plugin_manager.write().unwrap().unload_plugin(&name);
1525 match result {
1526 Ok(()) => {
1527 tracing::info!("Unloaded plugin: {}", name);
1528 if let Ok(mut schemas) = self.plugin_schemas.write() {
1529 schemas.remove(&name);
1530 }
1531 self.plugin_manager
1532 .read()
1533 .unwrap()
1534 .resolve_callback(callback_id, "true".to_string());
1535 }
1536 Err(e) => {
1537 tracing::error!("Failed to unload plugin '{}': {}", name, e);
1538 self.plugin_manager
1539 .read()
1540 .unwrap()
1541 .reject_callback(callback_id, format!("{}", e));
1542 }
1543 }
1544 }
1545
1546 #[cfg(feature = "plugins")]
1548 fn handle_reload_plugin(&mut self, name: String, callback_id: JsCallbackId) {
1549 let path = self
1553 .plugin_manager
1554 .read()
1555 .unwrap()
1556 .list_plugins()
1557 .into_iter()
1558 .find(|p| p.name == name)
1559 .map(|p| p.path);
1560 let _ = path; let reload_result = self.plugin_manager.read().unwrap().reload_plugin(&name);
1562 match reload_result {
1563 Ok(()) => {
1564 tracing::info!("Reloaded plugin: {}", name);
1565 self.plugin_manager
1566 .read()
1567 .unwrap()
1568 .resolve_callback(callback_id, "true".to_string());
1569 }
1570 Err(e) => {
1571 tracing::error!("Failed to reload plugin '{}': {}", name, e);
1572 self.plugin_manager
1573 .read()
1574 .unwrap()
1575 .reject_callback(callback_id, format!("{}", e));
1576 }
1577 }
1578 }
1579
1580 #[cfg(feature = "plugins")]
1582 fn handle_list_plugins(&mut self, callback_id: JsCallbackId) {
1583 let plugins = self.plugin_manager.read().unwrap().list_plugins();
1584 let json_array: Vec<serde_json::Value> = plugins
1586 .iter()
1587 .map(|p| {
1588 serde_json::json!({
1589 "name": p.name,
1590 "path": p.path.to_string_lossy(),
1591 "enabled": p.enabled
1592 })
1593 })
1594 .collect();
1595 let json_str = serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string());
1596 self.plugin_manager
1597 .read()
1598 .unwrap()
1599 .resolve_callback(callback_id, json_str);
1600 }
1601
1602 fn handle_execute_action(&mut self, action_name: String) {
1604 use crate::input::keybindings::Action;
1605 use std::collections::HashMap;
1606
1607 if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
1609 if let Err(e) = self.handle_action(action) {
1611 tracing::warn!("Failed to execute action '{}': {}", action_name, e);
1612 } else {
1613 tracing::debug!("Executed action: {}", action_name);
1614 }
1615 } else {
1616 tracing::warn!("Unknown action: {}", action_name);
1617 }
1618 }
1619
1620 fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
1623 use crate::input::keybindings::Action;
1624 use std::collections::HashMap;
1625
1626 for action_spec in actions {
1627 if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
1628 for _ in 0..action_spec.count {
1630 if let Err(e) = self.handle_action(action.clone()) {
1631 tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
1632 return; }
1634 }
1635 tracing::debug!(
1636 "Executed action '{}' {} time(s)",
1637 action_spec.action,
1638 action_spec.count
1639 );
1640 } else {
1641 tracing::warn!("Unknown action: {}", action_spec.action);
1642 return; }
1644 }
1645 }
1646
1647 fn handle_get_buffer_text(
1649 &mut self,
1650 buffer_id: BufferId,
1651 start: usize,
1652 end: usize,
1653 request_id: u64,
1654 ) {
1655 let result = if let Some(state) = self
1656 .windows
1657 .get_mut(&self.active_window)
1658 .map(|w| &mut w.buffers)
1659 .expect("active window present")
1660 .get_mut(&buffer_id)
1661 {
1662 let len = state.buffer.len();
1664 if start <= end && end <= len {
1665 Ok(state.get_text_range(start, end))
1666 } else {
1667 Err(format!(
1668 "Invalid range {}..{} for buffer of length {}",
1669 start, end, len
1670 ))
1671 }
1672 } else {
1673 Err(format!("Buffer {:?} not found", buffer_id))
1674 };
1675
1676 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1678 match result {
1679 Ok(text) => {
1680 let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
1682 self.plugin_manager
1683 .read()
1684 .unwrap()
1685 .resolve_callback(callback_id, json);
1686 }
1687 Err(error) => {
1688 self.plugin_manager
1689 .read()
1690 .unwrap()
1691 .reject_callback(callback_id, error);
1692 }
1693 }
1694 }
1695
1696 fn handle_set_editor_mode(&mut self, mode: Option<String>) {
1698 self.active_window_mut().editor_mode = mode.clone();
1699 tracing::debug!("Set editor mode: {:?}", mode);
1700 }
1701
1702 fn resolve_buffer_id(&self, buffer_id: BufferId) -> BufferId {
1704 if buffer_id.0 == 0 {
1705 self.active_buffer()
1706 } else {
1707 buffer_id
1708 }
1709 }
1710
1711 fn resolve_json_callback<T: serde::Serialize>(&mut self, request_id: u64, value: T) {
1713 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
1714 let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1715 self.plugin_manager
1716 .read()
1717 .unwrap()
1718 .resolve_callback(callback_id, json);
1719 }
1720
1721 fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1723 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1724 let result = self
1725 .windows
1726 .get_mut(&self.active_window)
1727 .map(|w| &mut w.buffers)
1728 .expect("active window present")
1729 .get_mut(&actual_buffer_id)
1730 .and_then(|state| {
1731 let len = state.buffer.len();
1732 let content = state.get_text_range(0, len);
1733 buffer_line_byte_offset(&content, len, line as usize, false)
1734 });
1735 self.resolve_json_callback(request_id, result);
1736 }
1737
1738 fn handle_get_line_end_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
1741 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1742 let result = self
1743 .windows
1744 .get_mut(&self.active_window)
1745 .map(|w| &mut w.buffers)
1746 .expect("active window present")
1747 .get_mut(&actual_buffer_id)
1748 .and_then(|state| {
1749 let len = state.buffer.len();
1750 let content = state.get_text_range(0, len);
1751 buffer_line_byte_offset(&content, len, line as usize, true)
1752 });
1753 self.resolve_json_callback(request_id, result);
1754 }
1755
1756 fn handle_get_buffer_line_count(&mut self, buffer_id: BufferId, request_id: u64) {
1758 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1759
1760 let result = if let Some(state) = self
1761 .windows
1762 .get_mut(&self.active_window)
1763 .map(|w| &mut w.buffers)
1764 .expect("active window present")
1765 .get_mut(&actual_buffer_id)
1766 {
1767 let buffer_len = state.buffer.len();
1768 let content = state.get_text_range(0, buffer_len);
1769
1770 if content.is_empty() {
1772 Some(1) } else {
1774 let newline_count = content.chars().filter(|&c| c == '\n').count();
1775 let ends_with_newline = content.ends_with('\n');
1777 if ends_with_newline {
1778 Some(newline_count)
1779 } else {
1780 Some(newline_count + 1)
1781 }
1782 }
1783 } else {
1784 None
1785 };
1786
1787 self.resolve_json_callback(request_id, result);
1788 }
1789
1790 fn handle_open_file_streaming(&mut self, path: std::path::PathBuf, request_id: u64) {
1807 if !self.authority.filesystem.exists(&path) {
1810 if let Some(parent) = path.parent() {
1811 if !parent.as_os_str().is_empty() {
1812 if let Err(e) = std::fs::create_dir_all(parent) {
1813 tracing::warn!(
1814 "openFileStreaming: failed to create parent dir {:?}: {}",
1815 parent,
1816 e
1817 );
1818 self.resolve_json_callback::<Option<u64>>(request_id, None);
1819 return;
1820 }
1821 }
1822 }
1823 if let Err(e) = std::fs::write(&path, b"") {
1824 tracing::warn!(
1825 "openFileStreaming: failed to create empty file at {:?}: {}",
1826 path,
1827 e
1828 );
1829 self.resolve_json_callback::<Option<u64>>(request_id, None);
1830 return;
1831 }
1832 }
1833
1834 let buffer_id = match self.open_file_no_focus(&path) {
1838 Ok(id) => id,
1839 Err(e) => {
1840 tracing::warn!(
1841 "openFileStreaming: open_file_no_focus failed for {:?}: {}",
1842 path,
1843 e
1844 );
1845 self.resolve_json_callback::<Option<u64>>(request_id, None);
1846 return;
1847 }
1848 };
1849
1850 if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
1855 meta.hidden_from_tabs = true;
1856 meta.auto_revert_enabled = false;
1857 }
1858 let active_split = self
1859 .windows
1860 .get(&self.active_window)
1861 .and_then(|w| w.buffers.splits())
1862 .map(|(mgr, _)| mgr)
1863 .expect("active window must have a populated split layout")
1864 .active_split();
1865 if let Some(vs) = self
1866 .windows
1867 .get_mut(&self.active_window)
1868 .and_then(|w| w.split_view_states_mut())
1869 .expect("active window must have a populated split layout")
1870 .get_mut(&active_split)
1871 {
1872 use crate::view::split::TabTarget;
1873 vs.open_buffers
1874 .retain(|t| !matches!(t, TabTarget::Buffer(b) if *b == buffer_id));
1875 }
1876
1877 self.resolve_json_callback(request_id, Some(buffer_id.0));
1878 }
1879
1880 fn handle_set_buffer_group_panel_buffer(
1883 &mut self,
1884 group_id: usize,
1885 panel_name: String,
1886 buffer_id: BufferId,
1887 request_id: u64,
1888 ) {
1889 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1890 let ok = self.set_buffer_group_panel_buffer(group_id, panel_name, actual_buffer_id);
1891 self.resolve_json_callback(request_id, ok);
1892 }
1893
1894 fn handle_refresh_buffer_from_disk(&mut self, buffer_id: BufferId, request_id: u64) {
1898 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1899
1900 let path = self
1901 .windows
1902 .get(&self.active_window)
1903 .and_then(|w| w.buffers.splits())
1904 .map(|(_, _)| ())
1905 .and_then(|_| {
1906 self.windows
1907 .get(&self.active_window)?
1908 .buffers
1909 .get(&actual_buffer_id)?
1910 .buffer
1911 .file_path()
1912 .map(|p| p.to_path_buf())
1913 });
1914
1915 let Some(path) = path else {
1916 self.resolve_json_callback::<Option<usize>>(request_id, None);
1918 return;
1919 };
1920
1921 let new_size = match self.authority.filesystem.metadata(&path) {
1922 Ok(m) => m.size as usize,
1923 Err(_) => {
1924 self.resolve_json_callback::<Option<usize>>(request_id, None);
1925 return;
1926 }
1927 };
1928
1929 let new_total = if let Some(state) = self
1930 .windows
1931 .get_mut(&self.active_window)
1932 .map(|w| &mut w.buffers)
1933 .expect("active window present")
1934 .get_mut(&actual_buffer_id)
1935 {
1936 let old = state.buffer.total_bytes();
1937 if new_size > old {
1938 state.buffer.extend_streaming(&path, new_size);
1939 }
1940 state.buffer.total_bytes()
1941 } else {
1942 self.resolve_json_callback::<Option<usize>>(request_id, None);
1943 return;
1944 };
1945
1946 self.resolve_json_callback(request_id, Some(new_total));
1947 }
1948
1949 fn handle_scroll_to_line_center(
1951 &mut self,
1952 split_id: SplitId,
1953 buffer_id: BufferId,
1954 line: usize,
1955 ) {
1956 let actual_split_id = if split_id.0 == 0 {
1957 self.windows
1958 .get(&self.active_window)
1959 .and_then(|w| w.buffers.splits())
1960 .map(|(mgr, _)| mgr)
1961 .expect("active window must have a populated split layout")
1962 .active_split()
1963 } else {
1964 LeafId(split_id)
1965 };
1966 let actual_buffer_id = self.resolve_buffer_id(buffer_id);
1967
1968 let viewport_height = if let Some(view_state) = self
1970 .windows
1971 .get(&self.active_window)
1972 .and_then(|w| w.buffers.splits())
1973 .map(|(_, vs)| vs)
1974 .expect("active window must have a populated split layout")
1975 .get(&actual_split_id)
1976 {
1977 view_state.viewport.height as usize
1978 } else {
1979 return;
1980 };
1981
1982 let lines_above = viewport_height / 2;
1984 let target_line = line.saturating_sub(lines_above);
1985
1986 self.active_window_mut().scroll_split_viewport_to(
1987 actual_buffer_id,
1988 actual_split_id,
1989 target_line,
1990 true,
1991 );
1992 }
1993
1994 fn handle_scroll_buffer_to_line(&mut self, buffer_id: BufferId, line: usize) {
2004 if !self
2005 .windows
2006 .get(&self.active_window)
2007 .map(|w| &w.buffers)
2008 .expect("active window present")
2009 .contains_key(&buffer_id)
2010 {
2011 return;
2012 }
2013
2014 let mut target_leaves: Vec<LeafId> = Vec::new();
2016
2017 for leaf_id in self
2019 .windows
2020 .get(&self.active_window)
2021 .and_then(|w| w.buffers.splits())
2022 .map(|(mgr, _)| mgr)
2023 .expect("active window must have a populated split layout")
2024 .root()
2025 .leaf_split_ids()
2026 {
2027 if let Some(vs) = self
2028 .windows
2029 .get(&self.active_window)
2030 .and_then(|w| w.buffers.splits())
2031 .map(|(_, vs)| vs)
2032 .expect("active window must have a populated split layout")
2033 .get(&leaf_id)
2034 {
2035 if vs.active_buffer == buffer_id {
2036 target_leaves.push(leaf_id);
2037 }
2038 }
2039 }
2040
2041 for (_group_leaf_id, node) in self.active_window().grouped_subtrees.iter() {
2043 if let crate::view::split::SplitNode::Grouped { layout, .. } = node {
2044 for inner_leaf in layout.leaf_split_ids() {
2045 if let Some(vs) = self
2046 .windows
2047 .get(&self.active_window)
2048 .and_then(|w| w.buffers.splits())
2049 .map(|(_, vs)| vs)
2050 .expect("active window must have a populated split layout")
2051 .get(&inner_leaf)
2052 {
2053 if vs.active_buffer == buffer_id && !target_leaves.contains(&inner_leaf) {
2054 target_leaves.push(inner_leaf);
2055 }
2056 }
2057 }
2058 }
2059 }
2060
2061 if target_leaves.is_empty() {
2062 return;
2063 }
2064
2065 self.active_window_mut()
2066 .scroll_buffer_to_line_in_splits(buffer_id, &target_leaves, line);
2067 }
2068
2069 fn handle_spawn_host_process(
2070 &mut self,
2071 command: String,
2072 args: Vec<String>,
2073 cwd: Option<String>,
2074 callback_id: JsCallbackId,
2075 ) {
2076 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2091 use tokio::io::{AsyncReadExt, BufReader};
2092 use tokio::process::Command as TokioCommand;
2093
2094 let effective_cwd = cwd.or_else(|| {
2095 std::env::current_dir()
2096 .map(|p| p.to_string_lossy().to_string())
2097 .ok()
2098 });
2099 let sender = bridge.sender();
2100 let process_id = callback_id.as_u64();
2101
2102 if let crate::services::workspace_trust::SpawnDecision::Deny(reason) = self
2109 .authority
2110 .workspace_trust
2111 .decide(&command, effective_cwd.as_deref())
2112 {
2113 #[allow(clippy::let_underscore_must_use)]
2114 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2115 process_id,
2116 stdout: String::new(),
2117 stderr: reason,
2118 exit_code: -1,
2119 });
2120 return;
2121 }
2122
2123 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
2124 self.host_process_handles.insert(process_id, kill_tx);
2125
2126 runtime.spawn(async move {
2127 use crate::services::process_hidden::HideWindow;
2128 let mut cmd = TokioCommand::new(&command);
2129 cmd.args(&args);
2130 cmd.stdout(std::process::Stdio::piped());
2131 cmd.stderr(std::process::Stdio::piped());
2132 cmd.hide_window();
2133 if let Some(ref dir) = effective_cwd {
2134 cmd.current_dir(dir);
2135 }
2136 let mut child = match cmd.spawn() {
2137 Ok(c) => c,
2138 Err(e) => {
2139 #[allow(clippy::let_underscore_must_use)]
2140 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2141 process_id,
2142 stdout: String::new(),
2143 stderr: e.to_string(),
2144 exit_code: -1,
2145 });
2146 return;
2147 }
2148 };
2149
2150 let stdout_pipe = child.stdout.take();
2156 let stderr_pipe = child.stderr.take();
2157
2158 let stdout_fut = async {
2159 let mut buf = String::new();
2160 if let Some(s) = stdout_pipe {
2161 #[allow(clippy::let_underscore_must_use)]
2162 let _ = BufReader::new(s).read_to_string(&mut buf).await;
2163 }
2164 buf
2165 };
2166 let stderr_fut = async {
2167 let mut buf = String::new();
2168 if let Some(s) = stderr_pipe {
2169 #[allow(clippy::let_underscore_must_use)]
2170 let _ = BufReader::new(s).read_to_string(&mut buf).await;
2171 }
2172 buf
2173 };
2174 let wait_fut = async {
2175 tokio::select! {
2176 status = child.wait() => {
2177 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
2178 }
2179 _ = &mut kill_rx => {
2180 #[allow(clippy::let_underscore_must_use)]
2184 let _ = child.start_kill();
2185 child
2186 .wait()
2187 .await
2188 .map(|s| s.code().unwrap_or(-1))
2189 .unwrap_or(-1)
2190 }
2191 }
2192 };
2193 let (stdout, stderr, exit_code) = tokio::join!(stdout_fut, stderr_fut, wait_fut);
2194
2195 #[allow(clippy::let_underscore_must_use)]
2196 let _ = sender.send(AsyncMessage::PluginProcessOutput {
2197 process_id,
2198 stdout,
2199 stderr,
2200 exit_code,
2201 });
2202 });
2203 } else {
2204 self.plugin_manager
2205 .read()
2206 .unwrap()
2207 .reject_callback(callback_id, "Async runtime not available".to_string());
2208 }
2209 }
2210
2211 fn handle_spawn_background_process(
2212 &mut self,
2213 process_id: u64,
2214 command: String,
2215 args: Vec<String>,
2216 cwd: Option<String>,
2217 callback_id: JsCallbackId,
2218 ) {
2219 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
2221 use tokio::io::{AsyncBufReadExt, BufReader};
2222 use tokio::process::Command as TokioCommand;
2223
2224 let effective_cwd = cwd.unwrap_or_else(|| {
2225 std::env::current_dir()
2226 .map(|p| p.to_string_lossy().to_string())
2227 .unwrap_or_else(|_| ".".to_string())
2228 });
2229
2230 let sender = bridge.sender();
2231 let sender_stdout = sender.clone();
2232 let sender_stderr = sender.clone();
2233 let callback_id_u64 = callback_id.as_u64();
2234
2235 #[allow(clippy::let_underscore_must_use)]
2237 let handle = runtime.spawn(async move {
2238 use crate::services::process_hidden::HideWindow;
2239 let mut child = match TokioCommand::new(&command)
2240 .args(&args)
2241 .current_dir(&effective_cwd)
2242 .stdout(std::process::Stdio::piped())
2243 .stderr(std::process::Stdio::piped())
2244 .hide_window()
2245 .spawn()
2246 {
2247 Ok(child) => child,
2248 Err(e) => {
2249 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2250 fresh_core::api::PluginAsyncMessage::ProcessExit {
2251 process_id,
2252 callback_id: callback_id_u64,
2253 exit_code: -1,
2254 },
2255 ));
2256 tracing::error!("Failed to spawn background process: {}", e);
2257 return;
2258 }
2259 };
2260
2261 let stdout = child.stdout.take();
2263 let stderr = child.stderr.take();
2264 let pid = process_id;
2265
2266 if let Some(stdout) = stdout {
2268 let sender = sender_stdout;
2269 tokio::spawn(async move {
2270 let reader = BufReader::new(stdout);
2271 let mut lines = reader.lines();
2272 while let Ok(Some(line)) = lines.next_line().await {
2273 let _ =
2274 sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2275 fresh_core::api::PluginAsyncMessage::ProcessStdout {
2276 process_id: pid,
2277 data: line + "\n",
2278 },
2279 ));
2280 }
2281 });
2282 }
2283
2284 if let Some(stderr) = stderr {
2286 let sender = sender_stderr;
2287 tokio::spawn(async move {
2288 let reader = BufReader::new(stderr);
2289 let mut lines = reader.lines();
2290 while let Ok(Some(line)) = lines.next_line().await {
2291 let _ =
2292 sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2293 fresh_core::api::PluginAsyncMessage::ProcessStderr {
2294 process_id: pid,
2295 data: line + "\n",
2296 },
2297 ));
2298 }
2299 });
2300 }
2301
2302 let exit_code = match child.wait().await {
2304 Ok(status) => status.code().unwrap_or(-1),
2305 Err(_) => -1,
2306 };
2307
2308 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
2309 fresh_core::api::PluginAsyncMessage::ProcessExit {
2310 process_id,
2311 callback_id: callback_id_u64,
2312 exit_code,
2313 },
2314 ));
2315 });
2316
2317 self.background_process_handles
2319 .insert(process_id, handle.abort_handle());
2320 } else {
2321 self.plugin_manager
2323 .read()
2324 .unwrap()
2325 .reject_callback(callback_id, "Async runtime not available".to_string());
2326 }
2327 }
2328
2329 fn handle_create_virtual_buffer_with_content(
2330 &mut self,
2331 name: String,
2332 mode: String,
2333 read_only: bool,
2334 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2335 show_line_numbers: bool,
2336 show_cursors: bool,
2337 editing_disabled: bool,
2338 hidden_from_tabs: bool,
2339 request_id: Option<u64>,
2340 ) {
2341 let buffer_id =
2342 self.active_window_mut()
2343 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2344 tracing::info!(
2345 "Created virtual buffer '{}' with mode '{}' (id={:?})",
2346 name,
2347 mode,
2348 buffer_id
2349 );
2350
2351 if let Some(state) = self
2358 .windows
2359 .get_mut(&self.active_window)
2360 .map(|w| &mut w.buffers)
2361 .expect("active window present")
2362 .get_mut(&buffer_id)
2363 {
2364 state.margins.configure_for_line_numbers(show_line_numbers);
2365 state.show_cursors = show_cursors;
2366 state.editing_disabled = editing_disabled;
2367 tracing::debug!(
2368 "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
2369 buffer_id,
2370 show_line_numbers,
2371 show_cursors,
2372 editing_disabled
2373 );
2374 }
2375 let active_split = self
2376 .windows
2377 .get(&self.active_window)
2378 .and_then(|w| w.buffers.splits())
2379 .map(|(mgr, _)| mgr)
2380 .expect("active window must have a populated split layout")
2381 .active_split();
2382 if let Some(view_state) = self
2383 .windows
2384 .get_mut(&self.active_window)
2385 .and_then(|w| w.split_view_states_mut())
2386 .expect("active window must have a populated split layout")
2387 .get_mut(&active_split)
2388 {
2389 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2390 }
2391
2392 if hidden_from_tabs {
2394 if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
2395 meta.hidden_from_tabs = true;
2396 }
2397 }
2398
2399 match self.set_virtual_buffer_content(buffer_id, entries) {
2401 Ok(()) => {
2402 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
2403 self.set_active_buffer(buffer_id);
2405 tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
2406
2407 if let Some(req_id) = request_id {
2409 tracing::info!(
2410 "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
2411 req_id,
2412 buffer_id
2413 );
2414 let result = fresh_core::api::VirtualBufferResult {
2416 buffer_id: buffer_id.0 as u64,
2417 split_id: None,
2418 };
2419 self.plugin_manager.read().unwrap().resolve_callback(
2420 fresh_core::api::JsCallbackId::from(req_id),
2421 serde_json::to_string(&result).unwrap_or_default(),
2422 );
2423 tracing::info!(
2424 "CreateVirtualBufferWithContent: resolve_callback sent for request_id={}",
2425 req_id
2426 );
2427 }
2428 }
2429 Err(e) => {
2430 tracing::error!("Failed to set virtual buffer content: {}", e);
2431 }
2432 }
2433 }
2434
2435 fn handle_create_virtual_buffer_in_split(
2436 &mut self,
2437 name: String,
2438 mode: String,
2439 read_only: bool,
2440 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2441 ratio: f32,
2442 direction: Option<String>,
2443 panel_id: Option<String>,
2444 show_line_numbers: bool,
2445 show_cursors: bool,
2446 editing_disabled: bool,
2447 line_wrap: Option<bool>,
2448 before: bool,
2449 role: Option<String>,
2450 request_id: Option<u64>,
2451 ) {
2452 let split_role: Option<crate::view::split::SplitRole> = match role.as_deref() {
2455 Some("utility_dock") => Some(crate::view::split::SplitRole::UtilityDock),
2456 _ => None,
2457 };
2458
2459 if let Some(target_role) = split_role {
2465 if let Some(dock_leaf) = self
2466 .windows
2467 .get(&self.active_window)
2468 .and_then(|w| w.buffers.splits())
2469 .map(|(mgr, _)| mgr)
2470 .expect("active window must have a populated split layout")
2471 .find_leaf_by_role(target_role)
2472 {
2473 let source_split_before_create = self
2478 .windows
2479 .get(&self.active_window)
2480 .and_then(|w| w.buffers.splits())
2481 .map(|(mgr, _)| mgr)
2482 .expect("active window must have a populated split layout")
2483 .active_split();
2484 let buffer_id = self.active_window_mut().create_virtual_buffer(
2485 name.clone(),
2486 mode.clone(),
2487 read_only,
2488 );
2489 if let Some(state) = self
2490 .windows
2491 .get_mut(&self.active_window)
2492 .map(|w| &mut w.buffers)
2493 .expect("active window present")
2494 .get_mut(&buffer_id)
2495 {
2496 state.margins.configure_for_line_numbers(show_line_numbers);
2497 state.show_cursors = show_cursors;
2498 state.editing_disabled = editing_disabled;
2499 }
2500 if let Some(pid) = &panel_id {
2501 self.panel_ids_mut().insert(pid.clone(), buffer_id);
2502 }
2503 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2504 tracing::error!("Failed to set virtual buffer content (dock route): {}", e);
2505 return;
2506 }
2507
2508 self.windows
2512 .get_mut(&self.active_window)
2513 .and_then(|w| w.split_manager_mut())
2514 .expect("active window must have a populated split layout")
2515 .set_active_split(dock_leaf);
2516 self.active_window_mut()
2517 .set_pane_buffer(dock_leaf, buffer_id);
2518
2519 if dock_leaf != source_split_before_create {
2521 if let Some(source_view_state) = self
2522 .windows
2523 .get_mut(&self.active_window)
2524 .and_then(|w| w.split_view_states_mut())
2525 .expect("active window must have a populated split layout")
2526 .get_mut(&source_split_before_create)
2527 {
2528 source_view_state.remove_buffer(buffer_id);
2529 }
2530 }
2531
2532 if let Some(req_id) = request_id {
2533 let result = fresh_core::api::VirtualBufferResult {
2534 buffer_id: buffer_id.0 as u64,
2535 split_id: Some(dock_leaf.0 .0 as u64),
2536 };
2537 self.plugin_manager.read().unwrap().resolve_callback(
2538 fresh_core::api::JsCallbackId::from(req_id),
2539 serde_json::to_string(&result).unwrap_or_default(),
2540 );
2541 }
2542 tracing::info!(
2543 "Routed virtual buffer '{}' into existing utility dock {:?}",
2544 name,
2545 dock_leaf
2546 );
2547 return;
2548 }
2549 }
2552
2553 if let Some(pid) = &panel_id {
2555 if let Some(&existing_buffer_id) = self.panel_ids().get(pid) {
2556 if self
2558 .windows
2559 .get(&self.active_window)
2560 .map(|w| &w.buffers)
2561 .expect("active window present")
2562 .contains_key(&existing_buffer_id)
2563 {
2564 if let Err(e) = self.set_virtual_buffer_content(existing_buffer_id, entries) {
2566 tracing::error!("Failed to update panel content: {}", e);
2567 } else {
2568 tracing::info!("Updated existing panel '{}' content", pid);
2569 }
2570
2571 let splits = self
2573 .windows
2574 .get(&self.active_window)
2575 .and_then(|w| w.buffers.splits())
2576 .map(|(mgr, _)| mgr)
2577 .expect("active window must have a populated split layout")
2578 .splits_for_buffer(existing_buffer_id);
2579 if let Some(&split_id) = splits.first() {
2580 self.windows
2581 .get_mut(&self.active_window)
2582 .and_then(|w| w.split_manager_mut())
2583 .expect("active window must have a populated split layout")
2584 .set_active_split(split_id);
2585 self.active_window_mut()
2588 .set_pane_buffer(split_id, existing_buffer_id);
2589 tracing::debug!("Focused split {:?} containing panel buffer", split_id);
2590 }
2591
2592 if let Some(req_id) = request_id {
2594 let result = fresh_core::api::VirtualBufferResult {
2595 buffer_id: existing_buffer_id.0 as u64,
2596 split_id: splits.first().map(|s| s.0 .0 as u64),
2597 };
2598 self.plugin_manager.read().unwrap().resolve_callback(
2599 fresh_core::api::JsCallbackId::from(req_id),
2600 serde_json::to_string(&result).unwrap_or_default(),
2601 );
2602 }
2603 return;
2604 } else {
2605 tracing::warn!(
2607 "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
2608 pid,
2609 existing_buffer_id
2610 );
2611 self.panel_ids_mut().remove(pid);
2612 }
2614 }
2615 }
2616
2617 let source_split_before_create = self
2623 .windows
2624 .get(&self.active_window)
2625 .and_then(|w| w.buffers.splits())
2626 .map(|(mgr, _)| mgr)
2627 .expect("active window must have a populated split layout")
2628 .active_split();
2629
2630 let buffer_id =
2632 self.active_window_mut()
2633 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2634 tracing::info!(
2635 "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
2636 name,
2637 mode,
2638 buffer_id
2639 );
2640
2641 if let Some(state) = self
2643 .windows
2644 .get_mut(&self.active_window)
2645 .map(|w| &mut w.buffers)
2646 .expect("active window present")
2647 .get_mut(&buffer_id)
2648 {
2649 state.margins.configure_for_line_numbers(show_line_numbers);
2650 state.show_cursors = show_cursors;
2651 state.editing_disabled = editing_disabled;
2652 tracing::debug!(
2653 "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
2654 buffer_id,
2655 show_line_numbers,
2656 show_cursors,
2657 editing_disabled
2658 );
2659 }
2660
2661 if let Some(pid) = panel_id {
2663 self.panel_ids_mut().insert(pid, buffer_id);
2664 }
2665
2666 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2668 tracing::error!("Failed to set virtual buffer content: {}", e);
2669 return;
2670 }
2671
2672 let split_dir = match direction.as_deref() {
2674 Some("vertical") => crate::model::event::SplitDirection::Vertical,
2675 _ => crate::model::event::SplitDirection::Horizontal,
2676 };
2677
2678 let created_split_id =
2684 match if split_role == Some(crate::view::split::SplitRole::UtilityDock) {
2685 self.windows
2686 .get_mut(&self.active_window)
2687 .and_then(|w| w.split_manager_mut())
2688 .expect("active window must have a populated split layout")
2689 .split_root_positioned(split_dir, buffer_id, ratio, before)
2690 } else {
2691 self.windows
2692 .get_mut(&self.active_window)
2693 .and_then(|w| w.split_manager_mut())
2694 .expect("active window must have a populated split layout")
2695 .split_active_positioned(split_dir, buffer_id, ratio, before)
2696 } {
2697 Ok(new_split_id) => {
2698 if new_split_id != source_split_before_create {
2704 if let Some(source_view_state) = self
2705 .windows
2706 .get_mut(&self.active_window)
2707 .and_then(|w| w.split_view_states_mut())
2708 .expect("active window must have a populated split layout")
2709 .get_mut(&source_split_before_create)
2710 {
2711 source_view_state.remove_buffer(buffer_id);
2712 }
2713 }
2714 let mut view_state = SplitViewState::with_buffer(
2716 self.terminal_width,
2717 self.terminal_height,
2718 buffer_id,
2719 );
2720 view_state.apply_config_defaults(
2721 self.config.editor.line_numbers,
2722 self.config.editor.highlight_current_line,
2723 line_wrap.unwrap_or_else(|| {
2724 self.active_window().resolve_line_wrap_for_buffer(buffer_id)
2725 }),
2726 self.config.editor.wrap_indent,
2727 self.active_window()
2728 .resolve_wrap_column_for_buffer(buffer_id),
2729 self.config.editor.rulers.clone(),
2730 );
2731 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2733 self.windows
2734 .get_mut(&self.active_window)
2735 .and_then(|w| w.split_view_states_mut())
2736 .expect("active window must have a populated split layout")
2737 .insert(new_split_id, view_state);
2738
2739 self.windows
2741 .get_mut(&self.active_window)
2742 .and_then(|w| w.split_manager_mut())
2743 .expect("active window must have a populated split layout")
2744 .set_active_split(new_split_id);
2745 if let Some(target_role) = split_role {
2753 self.windows
2754 .get_mut(&self.active_window)
2755 .and_then(|w| w.split_manager_mut())
2756 .expect("active window must have a populated split layout")
2757 .clear_role(target_role);
2758 self.windows
2759 .get_mut(&self.active_window)
2760 .and_then(|w| w.split_manager_mut())
2761 .expect("active window must have a populated split layout")
2762 .set_leaf_role(new_split_id, Some(target_role));
2763 tracing::info!(
2764 "Tagged new dock leaf {:?} with role {:?}",
2765 new_split_id,
2766 target_role
2767 );
2768 }
2769
2770 tracing::info!(
2771 "Created {:?} split with virtual buffer {:?}",
2772 split_dir,
2773 buffer_id
2774 );
2775 Some(new_split_id)
2776 }
2777 Err(e) => {
2778 tracing::error!("Failed to create split: {}", e);
2779 self.set_active_buffer(buffer_id);
2781 None
2782 }
2783 };
2784
2785 if let Some(req_id) = request_id {
2788 tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
2789 let result = fresh_core::api::VirtualBufferResult {
2790 buffer_id: buffer_id.0 as u64,
2791 split_id: created_split_id.map(|s| s.0 .0 as u64),
2792 };
2793 self.plugin_manager.read().unwrap().resolve_callback(
2794 fresh_core::api::JsCallbackId::from(req_id),
2795 serde_json::to_string(&result).unwrap_or_default(),
2796 );
2797 }
2798 }
2799
2800 fn handle_create_virtual_buffer_in_existing_split(
2801 &mut self,
2802 name: String,
2803 mode: String,
2804 read_only: bool,
2805 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
2806 split_id: SplitId,
2807 show_line_numbers: bool,
2808 show_cursors: bool,
2809 editing_disabled: bool,
2810 line_wrap: Option<bool>,
2811 request_id: Option<u64>,
2812 ) {
2813 let buffer_id =
2815 self.active_window_mut()
2816 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
2817 tracing::info!(
2818 "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
2819 name,
2820 mode,
2821 split_id,
2822 buffer_id
2823 );
2824
2825 if let Some(state) = self
2827 .windows
2828 .get_mut(&self.active_window)
2829 .map(|w| &mut w.buffers)
2830 .expect("active window present")
2831 .get_mut(&buffer_id)
2832 {
2833 state.margins.configure_for_line_numbers(show_line_numbers);
2834 state.show_cursors = show_cursors;
2835 state.editing_disabled = editing_disabled;
2836 }
2837
2838 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
2840 tracing::error!("Failed to set virtual buffer content: {}", e);
2841 return;
2842 }
2843
2844 let leaf_id = LeafId(split_id);
2847 self.windows
2848 .get_mut(&self.active_window)
2849 .and_then(|w| w.split_manager_mut())
2850 .expect("active window must have a populated split layout")
2851 .set_active_split(leaf_id);
2852 self.active_window_mut().set_pane_buffer(leaf_id, buffer_id);
2853
2854 if let Some(view_state) = self
2860 .windows
2861 .get_mut(&self.active_window)
2862 .and_then(|w| w.split_view_states_mut())
2863 .expect("active window must have a populated split layout")
2864 .get_mut(&leaf_id)
2865 {
2866 view_state.switch_buffer(buffer_id);
2867 view_state.add_buffer(buffer_id);
2868 view_state.ensure_buffer_state(buffer_id).show_line_numbers = show_line_numbers;
2869
2870 if let Some(wrap) = line_wrap {
2872 view_state.active_state_mut().viewport.line_wrap_enabled = wrap;
2873 }
2874 }
2875
2876 tracing::info!(
2877 "Displayed virtual buffer {:?} in split {:?}",
2878 buffer_id,
2879 split_id
2880 );
2881
2882 if let Some(req_id) = request_id {
2884 let result = fresh_core::api::VirtualBufferResult {
2885 buffer_id: buffer_id.0 as u64,
2886 split_id: Some(split_id.0 as u64),
2887 };
2888 self.plugin_manager.read().unwrap().resolve_callback(
2889 fresh_core::api::JsCallbackId::from(req_id),
2890 serde_json::to_string(&result).unwrap_or_default(),
2891 );
2892 }
2893 }
2894
2895 fn handle_show_action_popup(
2896 &mut self,
2897 popup_id: String,
2898 title: String,
2899 message: String,
2900 actions: Vec<fresh_core::api::ActionPopupAction>,
2901 ) {
2902 tracing::info!(
2903 "Action popup requested: id={}, title={}, actions={}",
2904 popup_id,
2905 title,
2906 actions.len()
2907 );
2908
2909 let items: Vec<crate::model::event::PopupListItemData> = actions
2911 .iter()
2912 .map(|action| crate::model::event::PopupListItemData {
2913 text: action.label.clone(),
2914 detail: None,
2915 icon: None,
2916 data: Some(action.id.clone()),
2917 })
2918 .collect();
2919
2920 drop(actions);
2925
2926 let popup_data = crate::model::event::PopupData {
2928 kind: crate::model::event::PopupKindHint::List,
2929 title: Some(title),
2930 description: Some(message),
2931 transient: false,
2932 content: crate::model::event::PopupContentData::List { items, selected: 0 },
2933 position: crate::model::event::PopupPositionData::BottomRight,
2934 width: 60,
2935 max_height: 15,
2936 bordered: true,
2937 };
2938
2939 let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
2949 popup_obj.resolver = crate::view::popup::PopupResolver::PluginAction {
2950 popup_id: popup_id.clone(),
2951 };
2952
2953 {
2960 let theme = self.theme();
2961 popup_obj.background_style = ratatui::style::Style::default().bg(theme.popup_bg);
2962 popup_obj.border_style = ratatui::style::Style::default().fg(theme.popup_border_fg);
2963 }
2964
2965 while self
2977 .active_state()
2978 .popups
2979 .top()
2980 .is_some_and(|p| matches!(p.resolver, crate::view::popup::PopupResolver::LspStatus))
2981 {
2982 self.active_state_mut().popups.hide();
2983 }
2984
2985 let existing_idx = self.global_popups.all().iter().position(|p| {
2992 matches!(
2993 &p.resolver,
2994 crate::view::popup::PopupResolver::PluginAction { popup_id: id } if id == &popup_id,
2995 )
2996 });
2997 if let Some(idx) = existing_idx {
2998 if let Some(slot) = self.global_popups.get_mut(idx) {
2999 *slot = popup_obj;
3000 }
3001 } else {
3002 self.global_popups.show(popup_obj);
3003 }
3004 tracing::info!(
3005 "Action popup shown: id={}, stack_depth={}",
3006 popup_id,
3007 self.global_popups.all().len()
3008 );
3009 }
3010
3011 fn handle_set_lsp_menu_contributions(
3021 &mut self,
3022 plugin_id: String,
3023 language: String,
3024 items: Vec<fresh_core::api::LspMenuItem>,
3025 ) {
3026 let key = (language.clone(), plugin_id.clone());
3027 if items.is_empty() {
3028 self.active_window_mut().lsp_menu_contributions.remove(&key);
3029 } else {
3030 self.active_window_mut()
3031 .lsp_menu_contributions
3032 .insert(key, items);
3033 }
3034 self.refresh_lsp_status_popup_if_open();
3039 }
3040
3041 fn handle_create_window_with_terminal(
3042 &mut self,
3043 root: std::path::PathBuf,
3044 label: String,
3045 cwd: Option<String>,
3046 command: Option<Vec<String>>,
3047 title: Option<String>,
3048 request_id: u64,
3049 ) {
3050 let callback_id = JsCallbackId::from(request_id);
3051 if !root.is_absolute() {
3052 let msg = format!(
3053 "createWindowWithTerminal: root must be absolute, got {:?}",
3054 root
3055 );
3056 tracing::warn!("{}", msg);
3057 self.plugin_manager
3058 .read()
3059 .unwrap()
3060 .reject_callback(callback_id, msg);
3061 return;
3062 }
3063 let cwd_buf = cwd.map(std::path::PathBuf::from);
3064 match self.create_window_with_terminal(root, label, cwd_buf, command, title) {
3065 Ok((window_id, terminal_id, buffer_id)) => {
3066 let api_result = fresh_core::api::SessionWithTerminalResult {
3067 window_id: window_id.0,
3068 terminal_id: terminal_id.0 as u64,
3069 buffer_id: buffer_id.0 as u64,
3070 };
3071 self.plugin_manager.read().unwrap().resolve_callback(
3072 callback_id,
3073 serde_json::to_string(&api_result).unwrap_or_default(),
3074 );
3075 }
3076 Err(e) => {
3077 tracing::error!("createWindowWithTerminal failed: {e}");
3078 self.plugin_manager
3079 .read()
3080 .unwrap()
3081 .reject_callback(callback_id, format!("createWindowWithTerminal: {e}"));
3082 }
3083 }
3084 }
3085
3086 fn handle_create_terminal(
3087 &mut self,
3088 cwd: Option<String>,
3089 direction: Option<String>,
3090 ratio: Option<f32>,
3091 focus: Option<bool>,
3092 persistent: bool,
3093 target_session_id: Option<fresh_core::WindowId>,
3094 command: Option<Vec<String>>,
3095 title: Option<String>,
3096 request_id: u64,
3097 ) {
3098 let target_id = target_session_id
3105 .filter(|id| self.windows.contains_key(id))
3106 .unwrap_or(self.active_window);
3107 let is_active_target = target_id == self.active_window;
3108
3109 let cwd_buf = cwd.map(std::path::PathBuf::from);
3110 let split_direction = direction.as_deref().map(|d| match d {
3111 "horizontal" => crate::model::event::SplitDirection::Horizontal,
3112 _ => crate::model::event::SplitDirection::Vertical,
3113 });
3114
3115 let prev_active = if is_active_target {
3123 Some(self.active_window().active_buffer())
3124 } else {
3125 None
3126 };
3127
3128 let result = {
3129 let target = self
3130 .windows
3131 .get_mut(&target_id)
3132 .expect("target window present (existence checked above)");
3133 target.create_plugin_terminal(
3134 cwd_buf,
3135 split_direction,
3136 ratio,
3137 focus.unwrap_or(true),
3138 persistent,
3139 command,
3140 title.filter(|t| !t.is_empty()),
3141 )
3142 };
3143 match result {
3144 Ok((terminal_id, buffer_id, created_split_id)) => {
3145 if is_active_target {
3146 let new_active = self.active_window().active_buffer();
3147 if prev_active != Some(new_active) {
3148 #[cfg(feature = "plugins")]
3149 self.update_plugin_state_snapshot();
3150 #[cfg(feature = "plugins")]
3151 self.plugin_manager.read().unwrap().run_hook(
3152 "buffer_activated",
3153 crate::services::plugins::hooks::HookArgs::BufferActivated {
3154 buffer_id: new_active,
3155 },
3156 );
3157 }
3158 }
3159 let api_result = fresh_core::api::TerminalResult {
3160 buffer_id: buffer_id.0 as u64,
3161 terminal_id: terminal_id.0 as u64,
3162 split_id: created_split_id.map(|s| s.0 .0 as u64),
3163 };
3164 self.plugin_manager.read().unwrap().resolve_callback(
3165 fresh_core::api::JsCallbackId::from(request_id),
3166 serde_json::to_string(&api_result).unwrap_or_default(),
3167 );
3168 tracing::info!(
3169 "Plugin created terminal {:?} with buffer {:?} in window {:?}",
3170 terminal_id,
3171 buffer_id,
3172 target_id
3173 );
3174 }
3175 Err(e) => {
3176 tracing::error!("Failed to create terminal for plugin: {e}");
3177 self.plugin_manager.read().unwrap().reject_callback(
3178 fresh_core::api::JsCallbackId::from(request_id),
3179 format!("Failed to create terminal: {e}"),
3180 );
3181 }
3182 }
3183 }
3184
3185 fn handle_get_split_by_label(&mut self, label: String, request_id: u64) {
3188 let split_id = self
3189 .windows
3190 .get(&self.active_window)
3191 .and_then(|w| w.buffers.splits())
3192 .map(|(mgr, _)| mgr)
3193 .expect("active window must have a populated split layout")
3194 .find_split_by_label(&label);
3195 let callback_id = fresh_core::api::JsCallbackId::from(request_id);
3196 let json =
3197 serde_json::to_string(&split_id.map(|s| s.0 .0)).unwrap_or_else(|_| "null".to_string());
3198 self.plugin_manager
3199 .read()
3200 .unwrap()
3201 .resolve_callback(callback_id, json);
3202 }
3203
3204 fn handle_set_buffer_show_cursors(&mut self, buffer_id: BufferId, show: bool) {
3205 if let Some(state) = self
3206 .windows
3207 .get_mut(&self.active_window)
3208 .map(|w| &mut w.buffers)
3209 .expect("active window present")
3210 .get_mut(&buffer_id)
3211 {
3212 state.show_cursors = show;
3213 state.cursor_visibility_locked = true;
3216 } else {
3217 tracing::warn!("SetBufferShowCursors: buffer {:?} not found", buffer_id);
3218 }
3219 }
3220
3221 fn handle_override_theme_colors(
3222 &mut self,
3223 overrides: std::collections::HashMap<String, [u8; 3]>,
3224 ) {
3225 let pairs = overrides
3226 .into_iter()
3227 .map(|(k, [r, g, b])| (k, ratatui::style::Color::Rgb(r, g, b)));
3228 let applied = self.theme.write().unwrap().override_colors(pairs);
3229 if applied > 0 {
3230 self.reapply_all_overlays();
3233 }
3234 }
3235
3236 fn handle_await_next_key(&mut self, callback_id: fresh_core::api::JsCallbackId) {
3237 if let Some(payload) = self
3241 .active_window_mut()
3242 .pending_key_capture_buffer
3243 .pop_front()
3244 {
3245 let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
3246 self.plugin_manager
3247 .read()
3248 .unwrap()
3249 .resolve_callback(callback_id, json);
3250 } else {
3251 self.active_window_mut()
3252 .pending_next_key_callbacks
3253 .push_back(callback_id);
3254 }
3255 }
3256
3257 fn handle_spawn_process(
3258 &mut self,
3259 command: String,
3260 args: Vec<String>,
3261 cwd: Option<String>,
3262 stdout_to: Option<std::path::PathBuf>,
3263 callback_id: fresh_core::api::JsCallbackId,
3264 ) {
3265 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3266 let effective_cwd = cwd.or_else(|| {
3267 std::env::current_dir()
3268 .map(|p| p.to_string_lossy().to_string())
3269 .ok()
3270 });
3271 let sender = bridge.sender();
3272 let spawner = self.authority.process_spawner.clone();
3273
3274 let process_id = callback_id.as_u64();
3279 let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
3280 self.host_process_handles.insert(process_id, kill_tx);
3281
3282 runtime.spawn(async move {
3283 #[allow(clippy::let_underscore_must_use)]
3284 let outcome = spawner
3285 .spawn_cancellable(command, args, effective_cwd, stdout_to, kill_rx)
3286 .await;
3287 match outcome {
3288 Ok(result) => {
3289 #[allow(clippy::let_underscore_must_use)]
3290 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3291 process_id,
3292 stdout: result.stdout,
3293 stderr: result.stderr,
3294 exit_code: result.exit_code,
3295 });
3296 }
3297 Err(e) => {
3298 #[allow(clippy::let_underscore_must_use)]
3299 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3300 process_id,
3301 stdout: String::new(),
3302 stderr: e.to_string(),
3303 exit_code: -1,
3304 });
3305 }
3306 }
3307 });
3308 } else {
3309 self.plugin_manager
3310 .read()
3311 .unwrap()
3312 .reject_callback(callback_id, "Async runtime not available".to_string());
3313 }
3314 }
3315
3316 fn handle_kill_host_process(&mut self, process_id: u64) {
3317 if let Some(tx) = self.host_process_handles.remove(&process_id) {
3321 #[allow(clippy::let_underscore_must_use)]
3322 let _ = tx.send(());
3323 tracing::debug!("KillHostProcess: sent kill for process_id={}", process_id);
3324 } else {
3325 tracing::debug!(
3326 "KillHostProcess: unknown process_id={} (already exited?)",
3327 process_id
3328 );
3329 }
3330 }
3331
3332 fn handle_set_authority(&mut self, payload: serde_json::Value) {
3333 match serde_json::from_value::<crate::services::authority::AuthorityPayload>(payload) {
3336 Ok(parsed) => {
3337 let trust = std::sync::Arc::clone(&self.authority.workspace_trust);
3340 let env = std::sync::Arc::clone(&self.authority.env_provider);
3341 match crate::services::authority::Authority::from_plugin_payload(parsed, trust, env)
3342 {
3343 Ok(auth) => {
3344 tracing::info!("Plugin installed new authority");
3345 self.install_authority(auth);
3346 }
3347 Err(e) => {
3348 tracing::warn!("setAuthority: invalid payload: {}", e);
3349 self.set_status_message(format!("setAuthority rejected: {}", e));
3350 }
3351 }
3352 }
3353 Err(e) => {
3354 tracing::warn!("setAuthority: failed to parse payload: {}", e);
3355 self.set_status_message(format!("setAuthority rejected: {}", e));
3356 }
3357 }
3358 }
3359
3360 fn handle_set_remote_indicator_state(&mut self, state: serde_json::Value) {
3361 match serde_json::from_value::<crate::view::ui::status_bar::RemoteIndicatorOverride>(state)
3364 {
3365 Ok(over) => {
3366 self.remote_indicator_override = Some(over);
3367 }
3368 Err(e) => {
3369 tracing::warn!("setRemoteIndicatorState: invalid payload: {}", e);
3370 self.set_status_message(format!("setRemoteIndicatorState rejected: {}", e));
3371 }
3372 }
3373 }
3374
3375 fn handle_spawn_process_wait(
3376 &mut self,
3377 process_id: u64,
3378 callback_id: fresh_core::api::JsCallbackId,
3379 ) {
3380 tracing::warn!(
3381 "SpawnProcessWait not fully implemented - process_id={}",
3382 process_id
3383 );
3384 self.plugin_manager.read().unwrap().reject_callback(
3385 callback_id,
3386 format!(
3387 "SpawnProcessWait not yet fully implemented for process_id={}",
3388 process_id
3389 ),
3390 );
3391 }
3392
3393 fn handle_delay(&mut self, callback_id: fresh_core::api::JsCallbackId, duration_ms: u64) {
3394 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3395 let sender = bridge.sender();
3396 let callback_id_u64 = callback_id.as_u64();
3397 runtime.spawn(async move {
3398 tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
3399 #[allow(clippy::let_underscore_must_use)]
3400 let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
3401 fresh_core::api::PluginAsyncMessage::DelayComplete {
3402 callback_id: callback_id_u64,
3403 },
3404 ));
3405 });
3406 } else {
3407 std::thread::sleep(std::time::Duration::from_millis(duration_ms));
3408 self.plugin_manager
3409 .read()
3410 .unwrap()
3411 .resolve_callback(callback_id, "null".to_string());
3412 }
3413 }
3414
3415 fn handle_http_fetch(
3416 &mut self,
3417 url: String,
3418 target_path: std::path::PathBuf,
3419 callback_id: fresh_core::api::JsCallbackId,
3420 ) {
3421 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
3422 let sender = bridge.sender();
3423 let process_id = callback_id.as_u64();
3424
3425 runtime.spawn(async move {
3426 let fetch =
3427 tokio::task::spawn_blocking(move || fetch_url_to_file(&url, &target_path))
3428 .await;
3429
3430 let (stdout, stderr, exit_code) = match fetch {
3431 Ok(Ok(status)) => {
3432 if (200..300).contains(&status) {
3433 (String::new(), String::new(), 0)
3434 } else {
3435 (String::new(), format!("HTTP {}", status), i32::from(status))
3436 }
3437 }
3438 Ok(Err(e)) => (String::new(), e, -1),
3439 Err(e) => (String::new(), format!("fetch task failed: {}", e), -1),
3440 };
3441
3442 #[allow(clippy::let_underscore_must_use)]
3443 let _ = sender.send(AsyncMessage::PluginProcessOutput {
3444 process_id,
3445 stdout,
3446 stderr,
3447 exit_code,
3448 });
3449 });
3450 } else {
3451 self.plugin_manager
3452 .read()
3453 .unwrap()
3454 .reject_callback(callback_id, "Async runtime not available".to_string());
3455 }
3456 }
3457
3458 fn handle_kill_background_process(&mut self, process_id: u64) {
3459 if let Some(handle) = self.background_process_handles.remove(&process_id) {
3460 handle.abort();
3461 tracing::debug!("Killed background process {}", process_id);
3462 }
3463 }
3464
3465 fn handle_create_virtual_buffer(&mut self, name: String, mode: String, read_only: bool) {
3466 let buffer_id =
3467 self.active_window_mut()
3468 .create_virtual_buffer(name.clone(), mode.clone(), read_only);
3469 tracing::info!(
3470 "Created virtual buffer '{}' with mode '{}' (id={:?})",
3471 name,
3472 mode,
3473 buffer_id
3474 );
3475 }
3477
3478 fn handle_set_virtual_buffer_content(
3479 &mut self,
3480 buffer_id: BufferId,
3481 entries: Vec<fresh_core::text_property::TextPropertyEntry>,
3482 ) {
3483 match self.set_virtual_buffer_content(buffer_id, entries) {
3484 Ok(()) => {
3485 tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
3486 }
3487 Err(e) => {
3488 tracing::error!("Failed to set virtual buffer content: {}", e);
3489 }
3490 }
3491 }
3492
3493 fn handle_mount_widget_panel(
3494 &mut self,
3495 panel_id: u64,
3496 buffer_id: BufferId,
3497 spec: fresh_core::api::WidgetSpec,
3498 ) {
3499 let prev = std::collections::HashMap::new();
3504 let prev_focus = String::new();
3505 let panel_width = self.widget_panel_width(buffer_id);
3506 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
3507 let focus_cursor = out.focus_cursor;
3508 self.widget_registry.mount(
3509 panel_id,
3510 buffer_id,
3511 spec,
3512 out.hits,
3513 out.instance_states,
3514 out.focus_key,
3515 out.tabbable,
3516 );
3517 let entries = out.entries;
3518 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3519 tracing::error!(
3520 "Failed to render mounted widget panel {} into {:?}: {}",
3521 panel_id,
3522 buffer_id,
3523 e
3524 );
3525 } else {
3526 tracing::debug!(
3527 "Mounted widget panel {} into buffer {:?}",
3528 panel_id,
3529 buffer_id
3530 );
3531 }
3532 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3533 }
3534
3535 fn handle_update_widget_panel(&mut self, panel_id: u64, spec: fresh_core::api::WidgetSpec) {
3536 let prev = match self.widget_registry.instance_states(panel_id) {
3537 Some(s) => s.clone(),
3538 None => {
3539 tracing::debug!(
3540 "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
3541 panel_id
3542 );
3543 return;
3544 }
3545 };
3546 let prev_focus = self
3547 .widget_registry
3548 .focus_key(panel_id)
3549 .map(|s| s.to_string())
3550 .unwrap_or_default();
3551 let buffer_id_for_width = self
3552 .widget_registry
3553 .buffer_and_spec(panel_id)
3554 .map(|(b, _)| b)
3555 .unwrap_or(BufferId(0));
3556 let panel_width = self.widget_panel_width(buffer_id_for_width);
3557 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
3558 let focus_cursor = out.focus_cursor;
3559 let entries = out.entries;
3560 match self.widget_registry.update(
3561 panel_id,
3562 spec,
3563 out.hits,
3564 out.instance_states,
3565 out.focus_key,
3566 out.tabbable,
3567 ) {
3568 Ok(buffer_id) => {
3569 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3570 tracing::error!("Failed to render updated widget panel {}: {}", panel_id, e);
3571 }
3572 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3573 }
3574 Err(()) => {
3575 tracing::debug!(
3576 "UpdateWidgetPanel for unknown panel {} ignored (not mounted)",
3577 panel_id
3578 );
3579 }
3580 }
3581 }
3582
3583 fn apply_widget_focus_cursor(
3594 &mut self,
3595 buffer_id: BufferId,
3596 entries: &[fresh_core::text_property::TextPropertyEntry],
3597 focus_cursor: Option<crate::widgets::FocusCursor>,
3598 ) {
3599 let locked = self
3605 .windows
3606 .get(&self.active_window)
3607 .and_then(|w| w.buffers.get(&buffer_id))
3608 .map(|s| s.cursor_visibility_locked)
3609 .unwrap_or(false);
3610 if locked {
3611 return;
3612 }
3613
3614 let absolute_byte = focus_cursor.map(|fc| {
3615 let row = fc.buffer_row as usize;
3616 let prefix: usize = entries.iter().take(row).map(|e| e.text.len()).sum();
3617 prefix + fc.byte_in_row as usize
3618 });
3619
3620 if let Some(state) = self
3621 .windows
3622 .get_mut(&self.active_window)
3623 .map(|w| &mut w.buffers)
3624 .expect("active window present")
3625 .get_mut(&buffer_id)
3626 {
3627 state.show_cursors = absolute_byte.is_some();
3628 }
3629
3630 if let Some(byte) = absolute_byte {
3631 for vs in self
3632 .windows
3633 .get_mut(&self.active_window)
3634 .and_then(|w| w.split_view_states_mut())
3635 .expect("active window must have a populated split layout")
3636 .values_mut()
3637 {
3638 if vs.buffer_state(buffer_id).is_some() {
3639 let cursor = vs.cursors.primary_mut();
3640 cursor.position = byte;
3641 }
3642 }
3643 }
3644 }
3645
3646 fn widget_panel_width(&self, buffer_id: BufferId) -> u32 {
3655 let raw = self
3656 .windows
3657 .get(&self.active_window)
3658 .and_then(|w| w.buffers.splits())
3659 .map(|(_, vs)| vs)
3660 .expect("active window must have a populated split layout")
3661 .values()
3662 .find(|vs| vs.buffer_state(buffer_id).is_some() && vs.viewport.width > 0)
3663 .map(|vs| vs.viewport.width as u32)
3664 .unwrap_or_else(|| self.terminal_width.max(1) as u32);
3665 raw.saturating_sub(2).max(10)
3668 }
3669
3670 pub(super) fn rerender_widget_panel(&mut self, panel_id: u64) {
3676 let (buffer_id, is_floating, panel_width, out_pieces) = {
3685 let (buffer_id, spec) = match self.widget_registry.buffer_and_spec_ref(panel_id) {
3686 Some(s) => s,
3687 None => return,
3688 };
3689 let prev = self
3690 .widget_registry
3691 .instance_states(panel_id)
3692 .cloned()
3693 .unwrap_or_default();
3694 let prev_focus = self
3695 .widget_registry
3696 .focus_key(panel_id)
3697 .map(|s| s.to_string())
3698 .unwrap_or_default();
3699 let is_floating = buffer_id == FLOATING_PANEL_BUFFER_ID;
3700 let panel_width = if is_floating {
3701 self.floating_panel_inner_width()
3702 } else {
3703 self.widget_panel_width(buffer_id)
3704 };
3705 let out = crate::widgets::render_spec(spec, &prev, &prev_focus, panel_width);
3706 (buffer_id, is_floating, panel_width, out)
3707 };
3708 let _ = panel_width;
3709 let focus_cursor = out_pieces.focus_cursor;
3710 let entries = out_pieces.entries;
3711 let embeds = out_pieces.embeds;
3712 let overlays = out_pieces.overlays;
3713 if self
3714 .widget_registry
3715 .update_side_effects(
3716 panel_id,
3717 out_pieces.hits,
3718 out_pieces.instance_states,
3719 out_pieces.focus_key,
3720 out_pieces.tabbable,
3721 )
3722 .is_err()
3723 {
3724 tracing::warn!("rerender_widget_panel({}) lost panel mid-call", panel_id);
3725 return;
3726 }
3727 if is_floating {
3728 if let Some(fwp) = self.floating_widget_panel.as_mut() {
3729 if fwp.panel_id == panel_id {
3730 fwp.entries = entries;
3731 fwp.focus_cursor = focus_cursor;
3732 fwp.embeds = embeds;
3733 fwp.overlays = overlays;
3734 }
3735 }
3736 return;
3737 }
3738 if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
3739 tracing::error!("rerender_widget_panel({}) failed: {}", panel_id, e);
3740 }
3741 self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
3742 }
3743
3744 fn handle_widget_mutate(&mut self, panel_id: u64, mutation: fresh_core::api::WidgetMutation) {
3750 use fresh_core::api::WidgetMutation;
3751
3752 if self.widget_registry.get(panel_id).is_none() {
3754 tracing::debug!(
3755 "WidgetMutate for unknown panel {} ignored (not mounted)",
3756 panel_id
3757 );
3758 return;
3759 }
3760
3761 match mutation {
3762 WidgetMutation::SetValue {
3763 widget_key,
3764 value,
3765 cursor_byte,
3766 } => {
3767 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3774 let (scroll, multiline, completions, sel_idx, scroll_off) =
3782 match panel.instance_states.get(&widget_key) {
3783 Some(crate::widgets::WidgetInstanceState::Text {
3784 editor,
3785 scroll,
3786 completions,
3787 completion_selected_index,
3788 completion_scroll_offset,
3789 }) => (
3790 *scroll,
3791 editor.multiline,
3792 completions.clone(),
3793 *completion_selected_index,
3794 *completion_scroll_offset,
3795 ),
3796 _ => (0u32, true, Vec::new(), 0usize, 0u32),
3797 };
3798 let mut editor = if multiline {
3799 crate::primitives::text_edit::TextEdit::with_text(&value)
3800 } else {
3801 crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
3802 };
3803 let target = match cursor_byte {
3804 Some(c) if c >= 0 => (c as usize).min(value.len()),
3805 _ => value.len(),
3806 };
3807 editor.set_cursor_from_flat(target);
3808 panel.instance_states.insert(
3809 widget_key,
3810 crate::widgets::WidgetInstanceState::Text {
3811 editor,
3812 scroll,
3813 completions,
3814 completion_selected_index: sel_idx,
3815 completion_scroll_offset: scroll_off,
3816 },
3817 );
3818 }
3819 }
3820 WidgetMutation::SetChecked {
3821 widget_key,
3822 checked,
3823 } => {
3824 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3828 crate::widgets::set_toggle_checked_in_spec(
3829 &mut panel.spec,
3830 &widget_key,
3831 checked,
3832 );
3833 }
3834 }
3835 WidgetMutation::SetSelectedIndex { widget_key, index } => {
3836 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3838 let prev_scroll = match panel.instance_states.get(&widget_key) {
3839 Some(crate::widgets::WidgetInstanceState::List {
3840 scroll_offset, ..
3841 }) => *scroll_offset,
3842 _ => 0,
3843 };
3844 panel.instance_states.insert(
3845 widget_key,
3846 crate::widgets::WidgetInstanceState::List {
3847 scroll_offset: prev_scroll,
3848 selected_index: index,
3849 },
3850 );
3851 }
3852 }
3853 WidgetMutation::SetCompletions { widget_key, items } => {
3854 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3863 if let Some(crate::widgets::WidgetInstanceState::Text {
3864 completions,
3865 completion_selected_index,
3866 completion_scroll_offset,
3867 ..
3868 }) = panel.instance_states.get_mut(&widget_key)
3869 {
3870 *completions = items;
3871 *completion_selected_index = 0;
3872 *completion_scroll_offset = 0;
3873 }
3874 }
3875 }
3876 WidgetMutation::SetItems {
3877 widget_key,
3878 items,
3879 item_keys,
3880 } => {
3881 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3883 crate::widgets::set_list_items_in_spec(
3884 &mut panel.spec,
3885 &widget_key,
3886 items,
3887 item_keys,
3888 );
3889 }
3890 }
3891 WidgetMutation::SetExpandedKeys { widget_key, keys } => {
3892 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3894 let (prev_scroll, prev_sel) = match panel.instance_states.get(&widget_key) {
3895 Some(crate::widgets::WidgetInstanceState::Tree {
3896 scroll_offset,
3897 selected_index,
3898 ..
3899 }) => (*scroll_offset, *selected_index),
3900 _ => (0, -1),
3901 };
3902 let expanded: std::collections::HashSet<String> = keys.into_iter().collect();
3903 panel.instance_states.insert(
3904 widget_key,
3905 crate::widgets::WidgetInstanceState::Tree {
3906 scroll_offset: prev_scroll,
3907 selected_index: prev_sel,
3908 expanded_keys: expanded,
3909 },
3910 );
3911 }
3912 }
3913 WidgetMutation::SetCheckedKeys {
3914 widget_key,
3915 checked,
3916 keys,
3917 } => {
3918 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3926 crate::widgets::set_tree_checked_keys_in_spec(
3927 &mut panel.spec,
3928 &widget_key,
3929 checked,
3930 &keys,
3931 );
3932 }
3933 }
3934 WidgetMutation::AppendTreeNodes {
3935 widget_key,
3936 new_nodes,
3937 new_item_keys,
3938 } => {
3939 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3940 crate::widgets::append_tree_nodes_in_spec(
3941 &mut panel.spec,
3942 &widget_key,
3943 new_nodes,
3944 new_item_keys,
3945 );
3946 }
3947 }
3948 WidgetMutation::SetRawEntries {
3949 widget_key,
3950 entries,
3951 } => {
3952 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
3953 crate::widgets::set_raw_entries_in_spec(&mut panel.spec, &widget_key, entries);
3954 }
3955 }
3956 WidgetMutation::SetFocusKey { widget_key } => {
3957 self.widget_registry.set_focus_key(panel_id, widget_key);
3962 }
3963 }
3964
3965 self.rerender_widget_panel(panel_id);
3969 }
3970
3971 pub(super) fn handle_widget_command(
3972 &mut self,
3973 panel_id: u64,
3974 action: fresh_core::api::WidgetAction,
3975 ) {
3976 use fresh_core::api::WidgetAction;
3977 match action {
3978 WidgetAction::FocusAdvance { delta } => {
3979 self.handle_widget_focus_advance(panel_id, delta);
3980 }
3981 WidgetAction::Activate => {
3982 self.handle_widget_activate(panel_id);
3983 }
3984 WidgetAction::SelectMove { delta } => {
3985 self.handle_widget_select_move(panel_id, delta);
3986 }
3987 WidgetAction::TextInputKey { key } => {
3988 self.handle_widget_text_key(panel_id, &key);
3989 }
3990 WidgetAction::TextInputChar { text } => {
3991 self.handle_widget_text_char(panel_id, &text);
3992 }
3993 WidgetAction::Key { key } => {
3994 self.handle_widget_key(panel_id, &key);
3995 }
3996 }
3997 }
3998
3999 fn handle_widget_key(&mut self, panel_id: u64, key: &str) {
4000 let panel = match self.widget_registry.get(panel_id) {
4004 Some(p) => p,
4005 None => return,
4006 };
4007 let focus_key = panel.focus_key.clone();
4008 let widget = if focus_key.is_empty() {
4009 None
4010 } else {
4011 crate::widgets::find_widget_by_key(&panel.spec, &focus_key)
4012 };
4013 let completions_open = matches!(key, "Tab" | "Up" | "Down" | "Enter" | "Escape")
4023 && self.focused_text_completions_open(panel_id);
4024 if completions_open {
4025 match key {
4026 "Tab" => {
4027 self.fire_completion_accept(panel_id);
4028 return;
4033 }
4034 "Up" => {
4035 self.move_focused_text_completion_index(panel_id, -1);
4036 self.rerender_widget_panel(panel_id);
4041 return;
4042 }
4043 "Down" => {
4044 self.move_focused_text_completion_index(panel_id, 1);
4045 self.rerender_widget_panel(panel_id);
4046 return;
4047 }
4048 "Enter" | "Escape" => {
4049 self.dismiss_focused_text_completions(panel_id);
4050 self.rerender_widget_panel(panel_id);
4051 return;
4052 }
4053 _ => {}
4054 }
4055 }
4056 match key {
4057 "Tab" => self.handle_widget_focus_advance(panel_id, 1),
4058 "Shift+Tab" => self.handle_widget_focus_advance(panel_id, -1),
4059 "Up" | "Down" => {
4060 let delta = if key == "Up" { -1 } else { 1 };
4061 match widget {
4062 Some(fresh_core::api::WidgetSpec::List { .. }) => {
4063 self.handle_widget_select_move(panel_id, delta);
4064 }
4065 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4066 self.handle_widget_tree_select_move(panel_id, delta);
4067 }
4068 Some(fresh_core::api::WidgetSpec::Text { rows, .. }) if *rows > 1 => {
4069 self.handle_widget_text_key(panel_id, key);
4075 }
4076 _ => {
4077 let scrollable = self
4085 .widget_registry
4086 .get(panel_id)
4087 .and_then(|p| find_scrollable_widget_key(&p.spec));
4088 if let Some(target_key) = scrollable {
4089 let target_kind = self.widget_registry.get(panel_id).and_then(|p| {
4090 crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
4091 });
4092 match target_kind {
4093 Some(fresh_core::api::WidgetSpec::List { .. }) => {
4094 self.handle_widget_select_move_for_key(
4095 panel_id,
4096 &target_key,
4097 delta,
4098 );
4099 }
4100 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4101 self.handle_widget_tree_select_move_for_key(
4102 panel_id,
4103 &target_key,
4104 delta,
4105 );
4106 }
4107 _ => {}
4108 }
4109 }
4110 }
4111 }
4112 }
4113 "PageUp" | "PageDown" => {
4114 let page = match widget {
4118 Some(fresh_core::api::WidgetSpec::List { visible_rows, .. })
4119 | Some(fresh_core::api::WidgetSpec::Tree { visible_rows, .. }) => {
4120 visible_rows.saturating_sub(1).max(1) as i32
4121 }
4122 _ => 0,
4123 };
4124 if page == 0 {
4125 return;
4126 }
4127 let delta = if key == "PageUp" { -page } else { page };
4128 match widget {
4129 Some(fresh_core::api::WidgetSpec::List { .. }) => {
4130 self.handle_widget_select_move(panel_id, delta);
4131 }
4132 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4133 self.handle_widget_tree_select_move(panel_id, delta);
4134 }
4135 _ => {}
4136 }
4137 }
4138 "Left" | "Right" => match widget {
4139 Some(fresh_core::api::WidgetSpec::Text { .. }) => {
4140 self.handle_widget_text_key(panel_id, key);
4141 }
4142 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4143 self.handle_widget_tree_lateral(panel_id, key == "Right");
4144 }
4145 _ => {}
4146 },
4147 "Backspace" | "Delete" | "Home" | "End" => match widget {
4148 Some(fresh_core::api::WidgetSpec::Text { .. }) => {
4149 self.handle_widget_text_key(panel_id, key);
4150 }
4151 _ => {}
4152 },
4153 "Enter" => match widget {
4154 Some(fresh_core::api::WidgetSpec::Button { .. })
4155 | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
4156 self.handle_widget_activate(panel_id);
4157 }
4158 Some(fresh_core::api::WidgetSpec::List { .. }) => {
4159 self.fire_list_activate(panel_id, &focus_key);
4160 }
4161 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4162 self.fire_tree_activate(panel_id, &focus_key);
4163 }
4164 Some(fresh_core::api::WidgetSpec::Text { rows, .. }) => {
4165 if *rows > 1 {
4166 self.handle_widget_text_key(panel_id, "Enter");
4172 } else if let Some(target_key) = self
4173 .widget_registry
4174 .get(panel_id)
4175 .and_then(|p| find_scrollable_widget_key(&p.spec))
4176 {
4177 let kind = self.widget_registry.get(panel_id).and_then(|p| {
4183 crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
4184 });
4185 match kind {
4186 Some(fresh_core::api::WidgetSpec::List { .. }) => {
4187 self.fire_list_activate(panel_id, &target_key);
4188 }
4189 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4190 self.fire_tree_activate(panel_id, &target_key);
4191 }
4192 _ => {}
4193 }
4194 } else {
4195 self.handle_widget_focus_advance(panel_id, 1);
4198 }
4199 }
4200 _ => {}
4201 },
4202 "Space" => match widget {
4203 Some(fresh_core::api::WidgetSpec::Button { .. })
4204 | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
4205 self.handle_widget_activate(panel_id);
4206 }
4207 Some(fresh_core::api::WidgetSpec::Text { .. }) => {
4208 self.handle_widget_text_char(panel_id, " ");
4209 }
4210 Some(fresh_core::api::WidgetSpec::List { .. }) => {
4211 self.fire_list_activate(panel_id, &focus_key);
4212 }
4213 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4214 if !self.fire_tree_toggle_if_checkable(panel_id, &focus_key) {
4221 self.fire_tree_activate(panel_id, &focus_key);
4222 }
4223 }
4224 _ => {}
4225 },
4226 _ => {} }
4228 }
4229
4230 fn handle_widget_focus_advance(&mut self, panel_id: u64, delta: i32) {
4231 let panel = match self.widget_registry.get(panel_id) {
4232 Some(p) => p,
4233 None => return,
4234 };
4235 if panel.tabbable.is_empty() {
4236 return;
4237 }
4238 let cur_idx = panel
4239 .tabbable
4240 .iter()
4241 .position(|k| k == &panel.focus_key)
4242 .unwrap_or(0) as i32;
4243 let n = panel.tabbable.len() as i32;
4244 let new_idx = ((cur_idx + delta) % n + n) % n;
4245 let new_key = panel.tabbable[new_idx as usize].clone();
4246 self.set_panel_focus_and_notify(panel_id, new_key);
4247 self.rerender_widget_panel(panel_id);
4248 }
4249
4250 pub(crate) fn set_panel_focus_and_notify(&mut self, panel_id: u64, new_key: String) {
4260 let old_key = self
4261 .widget_registry
4262 .focus_key(panel_id)
4263 .map(|s| s.to_string())
4264 .unwrap_or_default();
4265 if old_key == new_key {
4266 return;
4267 }
4268 self.widget_registry
4269 .set_focus_key(panel_id, new_key.clone());
4270 if self
4271 .plugin_manager
4272 .read()
4273 .unwrap()
4274 .has_hook_handlers("widget_event")
4275 {
4276 self.plugin_manager.read().unwrap().run_hook(
4277 "widget_event",
4278 fresh_core::hooks::HookArgs::WidgetEvent {
4279 panel_id,
4280 widget_key: new_key,
4281 event_type: "focus".to_string(),
4282 payload: serde_json::json!({ "previous": old_key }),
4283 },
4284 );
4285 }
4286 }
4287
4288 fn handle_widget_activate(&mut self, panel_id: u64) {
4289 let panel = match self.widget_registry.get(panel_id) {
4293 Some(p) => p,
4294 None => return,
4295 };
4296 let focus_key = panel.focus_key.clone();
4297 if focus_key.is_empty() {
4298 return;
4299 }
4300 let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
4301 let (event_type, payload) = match widget {
4302 Some(fresh_core::api::WidgetSpec::Button { disabled: true, .. }) => return,
4309 Some(fresh_core::api::WidgetSpec::Button { .. }) => ("activate", serde_json::json!({})),
4310 Some(fresh_core::api::WidgetSpec::Toggle { checked, .. }) => {
4311 ("toggle", serde_json::json!({ "checked": !checked }))
4312 }
4313 _ => return,
4314 };
4315 if self
4316 .plugin_manager
4317 .read()
4318 .unwrap()
4319 .has_hook_handlers("widget_event")
4320 {
4321 self.plugin_manager.read().unwrap().run_hook(
4322 "widget_event",
4323 fresh_core::hooks::HookArgs::WidgetEvent {
4324 panel_id,
4325 widget_key: focus_key,
4326 event_type: event_type.to_string(),
4327 payload,
4328 },
4329 );
4330 }
4331 }
4332
4333 fn focused_text_completions_open(&self, panel_id: u64) -> bool {
4345 let panel = match self.widget_registry.get(panel_id) {
4346 Some(p) => p,
4347 None => return false,
4348 };
4349 if panel.focus_key.is_empty() {
4350 return false;
4351 }
4352 matches!(
4353 panel.instance_states.get(&panel.focus_key),
4354 Some(crate::widgets::WidgetInstanceState::Text { completions, .. })
4355 if !completions.is_empty()
4356 )
4357 }
4358
4359 fn move_focused_text_completion_index(&mut self, panel_id: u64, delta: i32) {
4370 let panel = match self.widget_registry.get(panel_id) {
4377 Some(p) => p,
4378 None => return,
4379 };
4380 let focus_key = panel.focus_key.clone();
4381 if focus_key.is_empty() {
4382 return;
4383 }
4384 let spec_visible_rows = match crate::widgets::find_widget_by_key(&panel.spec, &focus_key) {
4385 Some(fresh_core::api::WidgetSpec::Text {
4386 completions_visible_rows,
4387 ..
4388 }) => *completions_visible_rows,
4389 _ => 0,
4390 };
4391 let visible = if spec_visible_rows == 0 {
4392 5u32
4393 } else {
4394 spec_visible_rows
4395 };
4396 let panel = match self.widget_registry.get_mut(panel_id) {
4397 Some(p) => p,
4398 None => return,
4399 };
4400 if let Some(crate::widgets::WidgetInstanceState::Text {
4401 completions,
4402 completion_selected_index,
4403 completion_scroll_offset,
4404 ..
4405 }) = panel.instance_states.get_mut(&focus_key)
4406 {
4407 if completions.is_empty() {
4408 return;
4409 }
4410 let max = (completions.len() - 1) as i32;
4411 let cur = *completion_selected_index as i32;
4412 let next = (cur + delta).clamp(0, max);
4413 *completion_selected_index = next as usize;
4414 let next_u = next as u32;
4419 if next_u < *completion_scroll_offset {
4420 *completion_scroll_offset = next_u;
4421 } else if next_u >= *completion_scroll_offset + visible {
4422 *completion_scroll_offset = next_u + 1 - visible;
4423 }
4424 }
4425 }
4426
4427 fn dismiss_focused_text_completions(&mut self, panel_id: u64) {
4434 let focus_key = {
4435 let panel = match self.widget_registry.get_mut(panel_id) {
4436 Some(p) => p,
4437 None => return,
4438 };
4439 let focus_key = panel.focus_key.clone();
4440 if focus_key.is_empty() {
4441 return;
4442 }
4443 if let Some(crate::widgets::WidgetInstanceState::Text {
4444 completions,
4445 completion_selected_index,
4446 ..
4447 }) = panel.instance_states.get_mut(&focus_key)
4448 {
4449 if completions.is_empty() {
4450 return;
4451 }
4452 completions.clear();
4453 *completion_selected_index = 0;
4454 } else {
4455 return;
4456 }
4457 focus_key
4458 };
4459 if self
4460 .plugin_manager
4461 .read()
4462 .unwrap()
4463 .has_hook_handlers("widget_event")
4464 {
4465 self.plugin_manager.read().unwrap().run_hook(
4466 "widget_event",
4467 fresh_core::hooks::HookArgs::WidgetEvent {
4468 panel_id,
4469 widget_key: focus_key,
4470 event_type: "completion_dismiss".into(),
4471 payload: serde_json::json!({}),
4472 },
4473 );
4474 }
4475 }
4476
4477 fn fire_completion_accept(&mut self, panel_id: u64) {
4489 let (focus_key, value) = {
4490 let panel = match self.widget_registry.get(panel_id) {
4491 Some(p) => p,
4492 None => return,
4493 };
4494 let focus_key = panel.focus_key.clone();
4495 if focus_key.is_empty() {
4496 return;
4497 }
4498 match panel.instance_states.get(&focus_key) {
4499 Some(crate::widgets::WidgetInstanceState::Text {
4500 completions,
4501 completion_selected_index,
4502 ..
4503 }) if !completions.is_empty() => {
4504 let idx = (*completion_selected_index).min(completions.len() - 1);
4505 (focus_key, completions[idx].value.clone())
4506 }
4507 _ => return,
4508 }
4509 };
4510 if self
4511 .plugin_manager
4512 .read()
4513 .unwrap()
4514 .has_hook_handlers("widget_event")
4515 {
4516 self.plugin_manager.read().unwrap().run_hook(
4517 "widget_event",
4518 fresh_core::hooks::HookArgs::WidgetEvent {
4519 panel_id,
4520 widget_key: focus_key,
4521 event_type: "completion_accept".into(),
4522 payload: serde_json::json!({ "value": value }),
4523 },
4524 );
4525 }
4526 }
4527
4528 fn fire_list_activate(&mut self, panel_id: u64, focus_key: &str) {
4529 let panel = match self.widget_registry.get(panel_id) {
4530 Some(p) => p,
4531 None => return,
4532 };
4533 let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
4534 let (spec_sel, item_keys) = match widget {
4535 Some(fresh_core::api::WidgetSpec::List {
4536 selected_index,
4537 item_keys,
4538 ..
4539 }) => (*selected_index, item_keys.clone()),
4540 _ => return,
4541 };
4542 let sel = match panel.instance_states.get(focus_key) {
4543 Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
4544 *selected_index
4545 }
4546 _ => spec_sel,
4547 };
4548 if sel < 0 {
4549 return;
4550 }
4551 let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
4552 if self
4553 .plugin_manager
4554 .read()
4555 .unwrap()
4556 .has_hook_handlers("widget_event")
4557 {
4558 self.plugin_manager.read().unwrap().run_hook(
4559 "widget_event",
4560 fresh_core::hooks::HookArgs::WidgetEvent {
4561 panel_id,
4562 widget_key: focus_key.to_string(),
4563 event_type: "activate".into(),
4564 payload: serde_json::json!({
4565 "index": sel,
4566 "key": item_key,
4567 }),
4568 },
4569 );
4570 }
4571 }
4572
4573 fn handle_widget_select_move(&mut self, panel_id: u64, delta: i32) {
4574 let focus_key = match self.widget_registry.get(panel_id) {
4575 Some(p) => p.focus_key.clone(),
4576 None => return,
4577 };
4578 if focus_key.is_empty() {
4579 return;
4580 }
4581 self.handle_widget_select_move_for_key(panel_id, &focus_key, delta);
4582 }
4583
4584 pub(super) fn set_widget_list_selected_index(
4592 &mut self,
4593 panel_id: u64,
4594 widget_key: &str,
4595 index: i32,
4596 ) {
4597 if let Some(panel) = self.widget_registry.get_mut(panel_id) {
4598 let prev_scroll = match panel.instance_states.get(widget_key) {
4599 Some(crate::widgets::WidgetInstanceState::List { scroll_offset, .. }) => {
4600 *scroll_offset
4601 }
4602 _ => 0,
4603 };
4604 panel.instance_states.insert(
4605 widget_key.to_string(),
4606 crate::widgets::WidgetInstanceState::List {
4607 scroll_offset: prev_scroll,
4608 selected_index: index,
4609 },
4610 );
4611 }
4612 self.rerender_widget_panel(panel_id);
4613 }
4614
4615 fn handle_widget_select_move_for_key(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4621 let panel = match self.widget_registry.get(panel_id) {
4622 Some(p) => p,
4623 None => return,
4624 };
4625 let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4626 let (spec_sel, total, item_keys) = match widget {
4627 Some(fresh_core::api::WidgetSpec::List {
4628 selected_index,
4629 items,
4630 item_keys,
4631 ..
4632 }) => (*selected_index, items.len() as i32, item_keys.clone()),
4633 _ => return,
4634 };
4635 if total == 0 {
4636 return;
4637 }
4638 let cur_sel = match panel.instance_states.get(widget_key) {
4639 Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
4640 *selected_index
4641 }
4642 _ => spec_sel,
4643 };
4644 let raw = if cur_sel < 0 { 0 } else { cur_sel + delta };
4645 let new_sel = raw.clamp(0, total - 1);
4646 let new_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
4647 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4648 let cur_scroll = match panel_mut.instance_states.get(widget_key) {
4649 Some(crate::widgets::WidgetInstanceState::List { scroll_offset, .. }) => {
4650 *scroll_offset
4651 }
4652 _ => 0,
4653 };
4654 panel_mut.instance_states.insert(
4655 widget_key.to_string(),
4656 crate::widgets::WidgetInstanceState::List {
4657 scroll_offset: cur_scroll,
4658 selected_index: new_sel,
4659 },
4660 );
4661 }
4662 self.rerender_widget_panel(panel_id);
4663 if self
4664 .plugin_manager
4665 .read()
4666 .unwrap()
4667 .has_hook_handlers("widget_event")
4668 {
4669 self.plugin_manager.read().unwrap().run_hook(
4670 "widget_event",
4671 fresh_core::hooks::HookArgs::WidgetEvent {
4672 panel_id,
4673 widget_key: widget_key.to_string(),
4674 event_type: "select".into(),
4675 payload: serde_json::json!({ "index": new_sel, "key": new_key }),
4676 },
4677 );
4678 }
4679 }
4680
4681 fn handle_widget_tree_select_move(&mut self, panel_id: u64, delta: i32) {
4686 let focus_key = match self.widget_registry.get(panel_id) {
4687 Some(p) => p.focus_key.clone(),
4688 None => return,
4689 };
4690 if focus_key.is_empty() {
4691 return;
4692 }
4693 self.handle_widget_tree_select_move_for_key(panel_id, &focus_key, delta);
4694 }
4695
4696 fn handle_widget_tree_select_move_for_key(
4698 &mut self,
4699 panel_id: u64,
4700 widget_key: &str,
4701 delta: i32,
4702 ) {
4703 let panel = match self.widget_registry.get(panel_id) {
4704 Some(p) => p,
4705 None => return,
4706 };
4707 let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4708 let (spec_sel, nodes, item_keys) = match widget {
4709 Some(fresh_core::api::WidgetSpec::Tree {
4710 selected_index,
4711 nodes,
4712 item_keys,
4713 ..
4714 }) => (*selected_index, nodes.clone(), item_keys.clone()),
4715 _ => return,
4716 };
4717 if nodes.is_empty() {
4718 return;
4719 }
4720 let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
4721 Some(crate::widgets::WidgetInstanceState::Tree {
4722 selected_index,
4723 scroll_offset,
4724 expanded_keys,
4725 }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
4726 _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
4727 };
4728 let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
4729 if visible_indices.is_empty() {
4730 return;
4731 }
4732 let cur_pos = if cur_sel < 0 {
4733 if delta > 0 {
4734 -1
4735 } else {
4736 visible_indices.len() as i32
4737 }
4738 } else {
4739 visible_indices
4740 .iter()
4741 .position(|&v| v as i32 == cur_sel)
4742 .map(|p| p as i32)
4743 .unwrap_or(-1)
4744 };
4745 let new_pos = (cur_pos + delta).clamp(0, (visible_indices.len() as i32) - 1);
4746 let new_abs = visible_indices[new_pos as usize];
4747 let new_key = item_keys.get(new_abs).cloned().unwrap_or_default();
4748 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4749 panel_mut.instance_states.insert(
4750 widget_key.to_string(),
4751 crate::widgets::WidgetInstanceState::Tree {
4752 scroll_offset: cur_scroll,
4753 selected_index: new_abs as i32,
4754 expanded_keys: expanded,
4755 },
4756 );
4757 }
4758 self.rerender_widget_panel(panel_id);
4759 if self
4760 .plugin_manager
4761 .read()
4762 .unwrap()
4763 .has_hook_handlers("widget_event")
4764 {
4765 self.plugin_manager.read().unwrap().run_hook(
4766 "widget_event",
4767 fresh_core::hooks::HookArgs::WidgetEvent {
4768 panel_id,
4769 widget_key: widget_key.to_string(),
4770 event_type: "select".into(),
4771 payload: serde_json::json!({ "index": new_abs as i64, "key": new_key }),
4772 },
4773 );
4774 }
4775 }
4776
4777 pub(super) fn handle_widget_panel_wheel(
4787 &mut self,
4788 buffer_id: crate::model::event::BufferId,
4789 delta: i32,
4790 ) -> bool {
4791 let panels = self.widget_registry.panels_for_buffer(buffer_id);
4792 let mut consumed = false;
4793 for panel_id in panels {
4794 if self.focused_text_completions_open(panel_id) {
4800 self.scroll_focused_text_completions(panel_id, delta);
4801 self.rerender_widget_panel(panel_id);
4810 consumed = true;
4811 continue;
4812 }
4813 let spec = match self.widget_registry.get(panel_id) {
4814 Some(p) => p.spec.clone(),
4815 None => continue,
4816 };
4817 let Some(widget_key) = find_scrollable_widget_key(&spec) else {
4818 continue;
4819 };
4820 let widget = crate::widgets::find_widget_by_key(&spec, &widget_key);
4821 match widget {
4822 Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
4823 self.handle_widget_tree_wheel(panel_id, &widget_key, delta);
4824 consumed = true;
4825 }
4826 Some(fresh_core::api::WidgetSpec::List { .. }) => {
4827 self.handle_widget_list_wheel(panel_id, &widget_key, delta);
4828 consumed = true;
4829 }
4830 _ => {}
4831 }
4832 }
4833 consumed
4834 }
4835
4836 fn scroll_focused_text_completions(&mut self, panel_id: u64, delta: i32) {
4843 let panel = match self.widget_registry.get(panel_id) {
4844 Some(p) => p,
4845 None => return,
4846 };
4847 let focus_key = panel.focus_key.clone();
4848 if focus_key.is_empty() {
4849 return;
4850 }
4851 let spec_visible_rows = match crate::widgets::find_widget_by_key(&panel.spec, &focus_key) {
4852 Some(fresh_core::api::WidgetSpec::Text {
4853 completions_visible_rows,
4854 ..
4855 }) => *completions_visible_rows,
4856 _ => 0,
4857 };
4858 let visible = if spec_visible_rows == 0 {
4859 5u32
4860 } else {
4861 spec_visible_rows
4862 };
4863 let panel = match self.widget_registry.get_mut(panel_id) {
4864 Some(p) => p,
4865 None => return,
4866 };
4867 if let Some(crate::widgets::WidgetInstanceState::Text {
4868 completions,
4869 completion_scroll_offset,
4870 ..
4871 }) = panel.instance_states.get_mut(&focus_key)
4872 {
4873 if completions.is_empty() {
4874 return;
4875 }
4876 let total = completions.len() as u32;
4877 let max_scroll = total.saturating_sub(visible.min(total));
4878 let next = (*completion_scroll_offset as i32 + delta).clamp(0, max_scroll as i32);
4879 *completion_scroll_offset = next as u32;
4880 }
4881 }
4882
4883 fn handle_widget_tree_wheel(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4888 let panel = match self.widget_registry.get(panel_id) {
4889 Some(p) => p,
4890 None => return,
4891 };
4892 let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4893 let (visible_rows, nodes, item_keys) = match widget {
4894 Some(fresh_core::api::WidgetSpec::Tree {
4895 visible_rows,
4896 nodes,
4897 item_keys,
4898 ..
4899 }) => (*visible_rows, nodes.clone(), item_keys.clone()),
4900 _ => return,
4901 };
4902 if nodes.is_empty() {
4903 return;
4904 }
4905 let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
4906 Some(crate::widgets::WidgetInstanceState::Tree {
4907 selected_index,
4908 scroll_offset,
4909 expanded_keys,
4910 }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
4911 _ => (-1, 0, std::collections::HashSet::<String>::new()),
4912 };
4913 let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
4914 if visible_indices.is_empty() {
4915 return;
4916 }
4917 let visible = visible_rows.max(1);
4918 let total_visible = visible_indices.len() as u32;
4919 let max_scroll = total_visible.saturating_sub(visible);
4920 let new_scroll = (cur_scroll as i32 + delta).clamp(0, max_scroll as i32) as u32;
4921 if new_scroll == cur_scroll {
4922 return;
4923 }
4924 let cur_pos: Option<u32> = if cur_sel >= 0 {
4926 visible_indices
4927 .iter()
4928 .position(|&v| v as i32 == cur_sel)
4929 .map(|p| p as u32)
4930 } else {
4931 None
4932 };
4933 let new_sel_abs = match cur_pos {
4934 Some(pos) if pos < new_scroll => visible_indices[new_scroll as usize] as i32,
4935 Some(pos) if pos >= new_scroll + visible => {
4936 visible_indices[(new_scroll + visible - 1) as usize] as i32
4937 }
4938 _ => cur_sel,
4939 };
4940 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4941 panel_mut.instance_states.insert(
4942 widget_key.to_string(),
4943 crate::widgets::WidgetInstanceState::Tree {
4944 scroll_offset: new_scroll,
4945 selected_index: new_sel_abs,
4946 expanded_keys: expanded,
4947 },
4948 );
4949 }
4950 self.rerender_widget_panel(panel_id);
4951 }
4952
4953 fn handle_widget_list_wheel(&mut self, panel_id: u64, widget_key: &str, delta: i32) {
4955 let panel = match self.widget_registry.get(panel_id) {
4956 Some(p) => p,
4957 None => return,
4958 };
4959 let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
4960 let (visible_rows, total) = match widget {
4961 Some(fresh_core::api::WidgetSpec::List {
4962 visible_rows,
4963 items,
4964 ..
4965 }) => (*visible_rows, items.len() as u32),
4966 _ => return,
4967 };
4968 if total == 0 {
4969 return;
4970 }
4971 let (cur_sel, cur_scroll) = match panel.instance_states.get(widget_key) {
4972 Some(crate::widgets::WidgetInstanceState::List {
4973 selected_index,
4974 scroll_offset,
4975 }) => (*selected_index, *scroll_offset),
4976 _ => (-1, 0),
4977 };
4978 let visible = visible_rows.max(1);
4979 let max_scroll = total.saturating_sub(visible);
4980 let new_scroll = (cur_scroll as i32 + delta).clamp(0, max_scroll as i32) as u32;
4981 if new_scroll == cur_scroll {
4982 return;
4983 }
4984 let new_sel = if cur_sel < 0 {
4985 cur_sel
4986 } else if (cur_sel as u32) < new_scroll {
4987 new_scroll as i32
4988 } else if (cur_sel as u32) >= new_scroll + visible {
4989 (new_scroll + visible - 1) as i32
4990 } else {
4991 cur_sel
4992 };
4993 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
4994 panel_mut.instance_states.insert(
4995 widget_key.to_string(),
4996 crate::widgets::WidgetInstanceState::List {
4997 scroll_offset: new_scroll,
4998 selected_index: new_sel,
4999 },
5000 );
5001 }
5002 self.rerender_widget_panel(panel_id);
5003 }
5004
5005 fn handle_widget_tree_lateral(&mut self, panel_id: u64, is_right: bool) {
5015 let panel = match self.widget_registry.get(panel_id) {
5016 Some(p) => p,
5017 None => return,
5018 };
5019 let focus_key = panel.focus_key.clone();
5020 if focus_key.is_empty() {
5021 return;
5022 }
5023 let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
5024 let (spec_sel, nodes, item_keys) = match widget {
5025 Some(fresh_core::api::WidgetSpec::Tree {
5026 selected_index,
5027 nodes,
5028 item_keys,
5029 ..
5030 }) => (*selected_index, nodes.clone(), item_keys.clone()),
5031 _ => return,
5032 };
5033 if nodes.is_empty() {
5034 return;
5035 }
5036 let (cur_sel, cur_scroll, mut expanded) = match panel.instance_states.get(&focus_key) {
5037 Some(crate::widgets::WidgetInstanceState::Tree {
5038 selected_index,
5039 scroll_offset,
5040 expanded_keys,
5041 }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
5042 _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
5043 };
5044 if cur_sel < 0 {
5045 return;
5046 }
5047 let sel_idx = cur_sel as usize;
5048 let node = match nodes.get(sel_idx) {
5049 Some(n) => n,
5050 None => return,
5051 };
5052 let key = item_keys.get(sel_idx).cloned().unwrap_or_default();
5053 let was_expanded = !key.is_empty() && expanded.contains(&key);
5054
5055 let mut new_sel = cur_sel;
5056 let mut expansion_changed: Option<bool> = None; if is_right {
5058 if node.has_children && !was_expanded && !key.is_empty() {
5059 expanded.insert(key.clone());
5060 expansion_changed = Some(true);
5061 }
5062 } else if node.has_children && was_expanded && !key.is_empty() {
5063 expanded.remove(&key);
5064 expansion_changed = Some(false);
5065 } else if let Some(parent_idx) = crate::widgets::tree_parent_index(&nodes, sel_idx) {
5066 new_sel = parent_idx as i32;
5067 }
5068 if expansion_changed.is_none() && new_sel == cur_sel {
5070 return;
5071 }
5072 let final_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
5073 if let Some(panel_mut) = self.widget_registry.get_mut(panel_id) {
5074 panel_mut.instance_states.insert(
5075 focus_key.clone(),
5076 crate::widgets::WidgetInstanceState::Tree {
5077 scroll_offset: cur_scroll,
5078 selected_index: new_sel,
5079 expanded_keys: expanded,
5080 },
5081 );
5082 }
5083 self.rerender_widget_panel(panel_id);
5084 if self
5085 .plugin_manager
5086 .read()
5087 .unwrap()
5088 .has_hook_handlers("widget_event")
5089 {
5090 if let Some(now_expanded) = expansion_changed {
5091 self.plugin_manager.read().unwrap().run_hook(
5092 "widget_event",
5093 fresh_core::hooks::HookArgs::WidgetEvent {
5094 panel_id,
5095 widget_key: focus_key.clone(),
5096 event_type: "expand".into(),
5097 payload: serde_json::json!({
5098 "index": cur_sel as i64,
5099 "key": key,
5100 "expanded": now_expanded,
5101 }),
5102 },
5103 );
5104 } else if new_sel != cur_sel {
5105 self.plugin_manager.read().unwrap().run_hook(
5106 "widget_event",
5107 fresh_core::hooks::HookArgs::WidgetEvent {
5108 panel_id,
5109 widget_key: focus_key,
5110 event_type: "select".into(),
5111 payload: serde_json::json!({
5112 "index": new_sel as i64,
5113 "key": final_key,
5114 }),
5115 },
5116 );
5117 }
5118 }
5119 }
5120
5121 pub(crate) fn handle_widget_tree_expand_toggle(
5125 &mut self,
5126 panel_id: u64,
5127 widget_key: &str,
5128 item_key: &str,
5129 ) {
5130 if widget_key.is_empty() || item_key.is_empty() {
5131 return;
5132 }
5133 let now_expanded = {
5134 let panel = match self.widget_registry.get_mut(panel_id) {
5135 Some(p) => p,
5136 None => return,
5137 };
5138 let (cur_scroll, cur_sel, mut expanded) = match panel.instance_states.get(widget_key) {
5139 Some(crate::widgets::WidgetInstanceState::Tree {
5140 scroll_offset,
5141 selected_index,
5142 expanded_keys,
5143 }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
5144 _ => (0u32, -1i32, std::collections::HashSet::<String>::new()),
5145 };
5146 let next = if expanded.contains(item_key) {
5147 expanded.remove(item_key);
5148 false
5149 } else {
5150 expanded.insert(item_key.to_string());
5151 true
5152 };
5153 panel.instance_states.insert(
5154 widget_key.to_string(),
5155 crate::widgets::WidgetInstanceState::Tree {
5156 scroll_offset: cur_scroll,
5157 selected_index: cur_sel,
5158 expanded_keys: expanded,
5159 },
5160 );
5161 next
5162 };
5163 self.rerender_widget_panel(panel_id);
5164 if self
5165 .plugin_manager
5166 .read()
5167 .unwrap()
5168 .has_hook_handlers("widget_event")
5169 {
5170 self.plugin_manager.read().unwrap().run_hook(
5171 "widget_event",
5172 fresh_core::hooks::HookArgs::WidgetEvent {
5173 panel_id,
5174 widget_key: widget_key.to_string(),
5175 event_type: "expand".into(),
5176 payload: serde_json::json!({
5177 "key": item_key,
5178 "expanded": now_expanded,
5179 }),
5180 },
5181 );
5182 }
5183 }
5184
5185 fn fire_tree_toggle_if_checkable(&mut self, panel_id: u64, focus_key: &str) -> bool {
5199 let panel = match self.widget_registry.get(panel_id) {
5200 Some(p) => p,
5201 None => return false,
5202 };
5203 let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
5204 let (spec_sel, nodes, item_keys, checkable) = match widget {
5205 Some(fresh_core::api::WidgetSpec::Tree {
5206 selected_index,
5207 nodes,
5208 item_keys,
5209 checkable,
5210 ..
5211 }) => (*selected_index, nodes, item_keys.clone(), *checkable),
5212 _ => return false,
5213 };
5214 if !checkable {
5215 return false;
5216 }
5217 let sel = match panel.instance_states.get(focus_key) {
5218 Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
5219 *selected_index
5220 }
5221 _ => spec_sel,
5222 };
5223 if sel < 0 {
5224 return false;
5225 }
5226 let cur_checked = match nodes.get(sel as usize).and_then(|n| n.checked) {
5227 Some(b) => b,
5228 None => return false, };
5230 let new_checked = !cur_checked;
5231 let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
5232 if self
5233 .plugin_manager
5234 .read()
5235 .unwrap()
5236 .has_hook_handlers("widget_event")
5237 {
5238 self.plugin_manager.read().unwrap().run_hook(
5239 "widget_event",
5240 fresh_core::hooks::HookArgs::WidgetEvent {
5241 panel_id,
5242 widget_key: focus_key.to_string(),
5243 event_type: "toggle".into(),
5244 payload: serde_json::json!({
5245 "index": sel,
5246 "key": item_key,
5247 "checked": new_checked,
5248 }),
5249 },
5250 );
5251 }
5252 true
5253 }
5254
5255 fn fire_tree_activate(&mut self, panel_id: u64, focus_key: &str) {
5256 let panel = match self.widget_registry.get(panel_id) {
5257 Some(p) => p,
5258 None => return,
5259 };
5260 let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
5261 let (spec_sel, item_keys) = match widget {
5262 Some(fresh_core::api::WidgetSpec::Tree {
5263 selected_index,
5264 item_keys,
5265 ..
5266 }) => (*selected_index, item_keys.clone()),
5267 _ => return,
5268 };
5269 let sel = match panel.instance_states.get(focus_key) {
5270 Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
5271 *selected_index
5272 }
5273 _ => spec_sel,
5274 };
5275 if sel < 0 {
5276 return;
5277 }
5278 let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
5279 if self
5280 .plugin_manager
5281 .read()
5282 .unwrap()
5283 .has_hook_handlers("widget_event")
5284 {
5285 self.plugin_manager.read().unwrap().run_hook(
5286 "widget_event",
5287 fresh_core::hooks::HookArgs::WidgetEvent {
5288 panel_id,
5289 widget_key: focus_key.to_string(),
5290 event_type: "activate".into(),
5291 payload: serde_json::json!({
5292 "index": sel,
5293 "key": item_key,
5294 }),
5295 },
5296 );
5297 }
5298 }
5299
5300 pub(super) fn focused_text_widget_panel_for_buffer(
5313 &self,
5314 buffer_id: crate::model::event::BufferId,
5315 ) -> Option<u64> {
5316 for panel_id in self.widget_registry.panels_for_buffer(buffer_id) {
5317 let panel = self.widget_registry.get(panel_id)?;
5318 if panel.focus_key.is_empty() {
5319 continue;
5320 }
5321 let widget = crate::widgets::find_widget_by_key(&panel.spec, &panel.focus_key);
5322 if matches!(widget, Some(fresh_core::api::WidgetSpec::Text { .. })) {
5323 return Some(panel_id);
5324 }
5325 }
5326 None
5327 }
5328
5329 pub(super) fn focused_widget_selected_text(&self, panel_id: u64) -> Option<String> {
5334 let panel = self.widget_registry.get(panel_id)?;
5335 if panel.focus_key.is_empty() {
5336 return None;
5337 }
5338 match panel.instance_states.get(&panel.focus_key) {
5339 Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
5340 editor.selected_text()
5341 }
5342 _ => None,
5343 }
5344 }
5345
5346 pub(super) fn handle_widget_select_all(&mut self, panel_id: u64) -> bool {
5351 self.with_focused_text_editor(panel_id, |editor| editor.select_all())
5355 }
5356
5357 pub(super) fn handle_widget_copy(&mut self, panel_id: u64) -> bool {
5362 if self.widget_registry.get(panel_id).is_none() {
5363 return false;
5364 }
5365 if let Some(text) = self.focused_widget_selected_text(panel_id) {
5366 self.clipboard.copy(text);
5367 }
5368 true
5369 }
5370
5371 pub(super) fn handle_widget_cut(&mut self, panel_id: u64) -> bool {
5374 if self.widget_registry.get(panel_id).is_none() {
5375 return false;
5376 }
5377 if let Some(text) = self.focused_widget_selected_text(panel_id) {
5378 self.clipboard.copy(text);
5379 self.with_focused_text_editor(panel_id, |editor| {
5380 editor.delete_selection();
5381 });
5382 }
5383 true
5384 }
5385
5386 pub(super) fn handle_widget_insert_str(&mut self, panel_id: u64, text: &str) -> bool {
5392 if self.widget_registry.get(panel_id).is_none() {
5393 return false;
5394 }
5395 let owned = text.to_string();
5396 self.with_focused_text_editor(panel_id, move |editor| {
5397 editor.insert_str(&owned);
5398 });
5399 true
5400 }
5401
5402 fn ensure_focused_text_seeded(&mut self, panel_id: u64, focus_key: &str) -> bool {
5409 let panel = match self.widget_registry.get_mut(panel_id) {
5410 Some(p) => p,
5411 None => return false,
5412 };
5413 if matches!(
5414 panel.instance_states.get(focus_key),
5415 Some(crate::widgets::WidgetInstanceState::Text { .. })
5416 ) {
5417 return true;
5418 }
5419 let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
5420 let (value, cursor_byte, multiline) = match widget {
5421 Some(fresh_core::api::WidgetSpec::Text {
5422 value,
5423 cursor_byte,
5424 rows,
5425 ..
5426 }) => (value.clone(), *cursor_byte, *rows > 1),
5427 _ => return false,
5428 };
5429 let mut editor = if multiline {
5430 crate::primitives::text_edit::TextEdit::with_text(&value)
5431 } else {
5432 crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
5433 };
5434 let seed = if cursor_byte < 0 {
5435 value.len()
5436 } else {
5437 (cursor_byte as usize).min(value.len())
5438 };
5439 editor.set_cursor_from_flat(seed);
5440 panel.instance_states.insert(
5441 focus_key.to_string(),
5442 crate::widgets::WidgetInstanceState::Text {
5443 editor,
5444 scroll: 0,
5445 completions: Vec::new(),
5446 completion_selected_index: 0,
5447 completion_scroll_offset: 0,
5448 },
5449 );
5450 true
5451 }
5452
5453 pub(super) fn with_focused_text_editor<F>(&mut self, panel_id: u64, op: F) -> bool
5460 where
5461 F: FnOnce(&mut crate::primitives::text_edit::TextEdit),
5462 {
5463 let focus_key = match self.widget_registry.get(panel_id) {
5464 Some(p) if !p.focus_key.is_empty() => p.focus_key.clone(),
5465 _ => return false,
5466 };
5467 if !self.ensure_focused_text_seeded(panel_id, &focus_key) {
5468 return false;
5469 }
5470 let (before_value, before_cursor) = {
5471 let panel = self.widget_registry.get(panel_id).unwrap();
5472 match panel.instance_states.get(&focus_key) {
5473 Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
5474 (editor.value(), editor.flat_cursor_byte())
5475 }
5476 _ => return false,
5477 }
5478 };
5479 {
5480 let panel = self.widget_registry.get_mut(panel_id).unwrap();
5481 match panel.instance_states.get_mut(&focus_key) {
5482 Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => op(editor),
5483 _ => return false,
5484 }
5485 }
5486 let (after_value, after_cursor) = {
5487 let panel = self.widget_registry.get(panel_id).unwrap();
5488 match panel.instance_states.get(&focus_key) {
5489 Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
5490 (editor.value(), editor.flat_cursor_byte())
5491 }
5492 _ => return false,
5493 }
5494 };
5495 if after_value == before_value && after_cursor == before_cursor {
5496 return false;
5497 }
5498 self.rerender_widget_panel(panel_id);
5499 if self
5500 .plugin_manager
5501 .read()
5502 .unwrap()
5503 .has_hook_handlers("widget_event")
5504 {
5505 self.plugin_manager.read().unwrap().run_hook(
5506 "widget_event",
5507 fresh_core::hooks::HookArgs::WidgetEvent {
5508 panel_id,
5509 widget_key: focus_key.clone(),
5510 event_type: "change".into(),
5511 payload: serde_json::json!({
5512 "value": after_value,
5513 "cursorByte": after_cursor as i64,
5514 }),
5515 },
5516 );
5517 }
5518 true
5519 }
5520
5521 fn handle_widget_text_key(&mut self, panel_id: u64, key: &str) {
5527 self.with_focused_text_editor(panel_id, |editor| match key {
5528 "Backspace" => editor.backspace(),
5529 "Delete" => editor.delete(),
5530 "Left" => editor.move_left(),
5531 "Right" => editor.move_right(),
5532 "Up" => editor.move_up(),
5533 "Down" => editor.move_down(),
5534 "Home" => editor.move_home(),
5535 "End" => editor.move_end(),
5536 "Enter" => editor.insert_char('\n'),
5537 _ => { }
5538 });
5539 }
5540
5541 fn handle_widget_text_char(&mut self, panel_id: u64, text: &str) {
5548 if text.is_empty() {
5549 return;
5550 }
5551 let text = text.to_string();
5552 self.with_focused_text_editor(panel_id, move |editor| {
5553 editor.insert_str(&text);
5554 });
5555 }
5556
5557 fn handle_unmount_widget_panel(&mut self, panel_id: u64) {
5558 match self.widget_registry.unmount(panel_id) {
5559 Some(buffer_id) => {
5560 tracing::debug!(
5561 "Unmounted widget panel {} (was rendering into {:?})",
5562 panel_id,
5563 buffer_id
5564 );
5565 }
5570 None => {
5571 tracing::debug!("UnmountWidgetPanel for unknown panel {} ignored", panel_id);
5572 }
5573 }
5574 }
5575
5576 fn handle_mount_floating_widget(
5577 &mut self,
5578 panel_id: u64,
5579 spec: fresh_core::api::WidgetSpec,
5580 width_pct: u8,
5581 height_pct: u8,
5582 ) {
5583 let width_pct = width_pct.clamp(1, 100);
5584 let height_pct = height_pct.clamp(1, 100);
5585 if let Some(existing) = self.floating_widget_panel.take() {
5586 if existing.panel_id != panel_id {
5587 let _ = self.widget_registry.unmount(existing.panel_id);
5588 }
5589 }
5590 self.floating_widget_panel = Some(FloatingWidgetState {
5591 panel_id,
5592 width_pct,
5593 height_pct,
5594 entries: Vec::new(),
5595 focus_cursor: None,
5596 embeds: Vec::new(),
5597 overlays: Vec::new(),
5598 last_inner_rect: None,
5599 });
5600 let prev = std::collections::HashMap::new();
5601 let prev_focus = String::new();
5602 let panel_width = self.floating_panel_inner_width();
5603 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
5604 let focus_cursor = out.focus_cursor;
5605 let entries = out.entries;
5606 let embeds = out.embeds;
5607 let overlays = out.overlays;
5608 self.widget_registry.mount(
5609 panel_id,
5610 FLOATING_PANEL_BUFFER_ID,
5611 spec,
5612 out.hits,
5613 out.instance_states,
5614 out.focus_key,
5615 out.tabbable,
5616 );
5617 if let Some(fwp) = self.floating_widget_panel.as_mut() {
5618 fwp.entries = entries;
5619 fwp.focus_cursor = focus_cursor;
5620 fwp.embeds = embeds;
5621 fwp.overlays = overlays;
5622 }
5623 tracing::debug!(
5624 "Mounted floating widget panel {} ({}%x{}%)",
5625 panel_id,
5626 width_pct,
5627 height_pct
5628 );
5629 }
5630
5631 fn handle_update_floating_widget(&mut self, panel_id: u64, spec: fresh_core::api::WidgetSpec) {
5632 match self.floating_widget_panel.as_ref() {
5633 Some(fwp) if fwp.panel_id == panel_id => {}
5634 _ => {
5635 tracing::debug!(
5636 "UpdateFloatingWidget for unknown / mismatched panel {} ignored",
5637 panel_id
5638 );
5639 return;
5640 }
5641 }
5642 let prev = self
5643 .widget_registry
5644 .instance_states(panel_id)
5645 .cloned()
5646 .unwrap_or_default();
5647 let prev_focus = self
5648 .widget_registry
5649 .focus_key(panel_id)
5650 .map(|s| s.to_string())
5651 .unwrap_or_default();
5652 let panel_width = self.floating_panel_inner_width();
5653 let out = crate::widgets::render_spec(&spec, &prev, &prev_focus, panel_width);
5654 let focus_cursor = out.focus_cursor;
5655 let entries = out.entries;
5656 let embeds = out.embeds;
5657 let overlays = out.overlays;
5658 if self
5659 .widget_registry
5660 .update(
5661 panel_id,
5662 spec,
5663 out.hits,
5664 out.instance_states,
5665 out.focus_key,
5666 out.tabbable,
5667 )
5668 .is_err()
5669 {
5670 tracing::debug!(
5671 "UpdateFloatingWidget for unknown panel {} ignored (not in registry)",
5672 panel_id
5673 );
5674 return;
5675 }
5676 if let Some(fwp) = self.floating_widget_panel.as_mut() {
5677 fwp.entries = entries;
5678 fwp.focus_cursor = focus_cursor;
5679 fwp.embeds = embeds;
5680 fwp.overlays = overlays;
5681 }
5682 }
5683
5684 fn handle_unmount_floating_widget(&mut self, panel_id: u64) {
5685 match self.floating_widget_panel.as_ref() {
5686 Some(fwp) if fwp.panel_id == panel_id => {}
5687 _ => {
5688 tracing::debug!(
5689 "UnmountFloatingWidget for unknown / mismatched panel {} ignored",
5690 panel_id
5691 );
5692 return;
5693 }
5694 }
5695 self.floating_widget_panel = None;
5696 let _ = self.widget_registry.unmount(panel_id);
5697 self.active_window_mut().resize_visible_terminals();
5710 tracing::debug!("Unmounted floating widget panel {}", panel_id);
5711 }
5712
5713 pub(super) fn floating_panel_inner_width(&self) -> u32 {
5719 let term_w = self.terminal_width.max(1) as u32;
5720 let pct = self
5721 .floating_widget_panel
5722 .as_ref()
5723 .map(|f| f.width_pct.clamp(1, 100) as u32)
5724 .unwrap_or(80);
5725 let w = (term_w * pct) / 100;
5726 w.saturating_sub(2).max(10)
5727 }
5728
5729 fn handle_get_text_properties_at_cursor(&self, buffer_id: BufferId) {
5730 if let Some(state) = self
5731 .windows
5732 .get(&self.active_window)
5733 .map(|w| &w.buffers)
5734 .expect("active window present")
5735 .get(&buffer_id)
5736 {
5737 let cursor_pos = self
5738 .windows
5739 .get(&self.active_window)
5740 .and_then(|w| w.buffers.splits())
5741 .map(|(_, vs)| vs)
5742 .expect("active window must have a populated split layout")
5743 .values()
5744 .find_map(|vs| vs.buffer_state(buffer_id))
5745 .map(|bs| bs.cursors.primary().position)
5746 .unwrap_or(0);
5747 let properties = state.text_properties.get_at(cursor_pos);
5748 tracing::debug!(
5749 "Text properties at cursor in {:?}: {} properties found",
5750 buffer_id,
5751 properties.len()
5752 );
5753 }
5755 }
5756
5757 fn handle_set_context(&mut self, name: String, active: bool) {
5758 if active {
5759 self.active_window_mut()
5760 .active_custom_contexts
5761 .insert(name.clone());
5762 tracing::debug!("Set custom context: {}", name);
5763 } else {
5764 self.active_window_mut()
5765 .active_custom_contexts
5766 .remove(&name);
5767 tracing::debug!("Unset custom context: {}", name);
5768 }
5769 }
5770
5771 fn handle_disable_lsp_for_language(&mut self, language: String) {
5772 tracing::info!("Disabling LSP for language: {}", language);
5773 let __active_id = self.active_window;
5774 if let Some(lsp) = self
5775 .windows
5776 .get_mut(&__active_id)
5777 .and_then(|w| w.lsp.as_mut())
5778 {
5779 lsp.shutdown_server(&language);
5780 tracing::info!("Stopped LSP server for {}", language);
5781 }
5782 if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&language) {
5783 for c in lsp_configs.as_mut_slice() {
5784 c.enabled = false;
5785 c.auto_start = false;
5786 }
5787 tracing::info!("Disabled LSP config for {}", language);
5788 }
5789 if let Err(e) = self.save_config() {
5790 tracing::error!("Failed to save config: {}", e);
5791 self.active_window_mut().status_message = Some(format!(
5792 "LSP disabled for {} (config save failed)",
5793 language
5794 ));
5795 } else {
5796 self.active_window_mut().status_message =
5797 Some(format!("LSP disabled for {}", language));
5798 }
5799 self.active_window_mut().warning_domains.lsp.clear();
5800 }
5801
5802 fn handle_restart_lsp_for_language(&mut self, language: String) {
5803 tracing::info!("Plugin restarting LSP for language: {}", language);
5804 let file_path = self
5805 .active_window()
5806 .buffer_metadata
5807 .get(&self.active_buffer())
5808 .and_then(|meta| meta.file_path().cloned());
5809 let __active_id = self.active_window;
5810 let success = if let Some(lsp) = self
5811 .windows
5812 .get_mut(&__active_id)
5813 .and_then(|w| w.lsp.as_mut())
5814 {
5815 let (ok, msg) = lsp.manual_restart(&language, file_path.as_deref());
5816 self.active_window_mut().status_message = Some(msg);
5817 ok
5818 } else {
5819 self.active_window_mut().status_message = Some("No LSP manager available".to_string());
5820 false
5821 };
5822 if success {
5823 self.reopen_buffers_for_language(&language);
5824 }
5825 }
5826
5827 fn handle_set_lsp_root_uri(&mut self, language: String, uri: String) {
5828 tracing::info!("Plugin setting LSP root URI for {}: {}", language, uri);
5829 match uri.parse::<lsp_types::Uri>() {
5830 Ok(parsed_uri) => {
5831 let __active_id = self.active_window;
5832 if let Some(lsp) = self
5833 .windows
5834 .get_mut(&__active_id)
5835 .and_then(|w| w.lsp.as_mut())
5836 {
5837 let restarted = lsp.set_language_root_uri(&language, parsed_uri);
5838 if restarted {
5839 self.active_window_mut().status_message = Some(format!(
5840 "LSP root updated for {} (restarting server)",
5841 language
5842 ));
5843 } else {
5844 self.active_window_mut().status_message =
5845 Some(format!("LSP root set for {}", language));
5846 }
5847 }
5848 }
5849 Err(e) => {
5850 tracing::error!("Invalid LSP root URI '{}': {}", uri, e);
5851 self.active_window_mut().status_message =
5852 Some(format!("Invalid LSP root URI: {}", e));
5853 }
5854 }
5855 }
5856
5857 fn handle_create_scroll_sync_group(
5858 &mut self,
5859 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5860 left_split: SplitId,
5861 right_split: SplitId,
5862 ) {
5863 let success = self
5864 .active_window_mut()
5865 .scroll_sync_manager
5866 .create_group_with_id(group_id, left_split, right_split);
5867 if success {
5868 tracing::debug!(
5869 "Created scroll sync group {} for splits {:?} and {:?}",
5870 group_id,
5871 left_split,
5872 right_split
5873 );
5874 } else {
5875 tracing::warn!(
5876 "Failed to create scroll sync group {} (ID already exists)",
5877 group_id
5878 );
5879 }
5880 }
5881
5882 fn handle_set_scroll_sync_anchors(
5883 &mut self,
5884 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5885 anchors: Vec<(usize, usize)>,
5886 ) {
5887 use crate::view::scroll_sync::SyncAnchor;
5888 let anchor_count = anchors.len();
5889 let sync_anchors: Vec<SyncAnchor> = anchors
5890 .into_iter()
5891 .map(|(left_line, right_line)| SyncAnchor {
5892 left_line,
5893 right_line,
5894 })
5895 .collect();
5896 self.active_window_mut()
5897 .scroll_sync_manager
5898 .set_anchors(group_id, sync_anchors);
5899 tracing::debug!(
5900 "Set {} anchors for scroll sync group {}",
5901 anchor_count,
5902 group_id
5903 );
5904 }
5905
5906 fn handle_remove_scroll_sync_group(
5907 &mut self,
5908 group_id: crate::view::scroll_sync::ScrollSyncGroupId,
5909 ) {
5910 if self
5911 .active_window_mut()
5912 .scroll_sync_manager
5913 .remove_group(group_id)
5914 {
5915 tracing::debug!("Removed scroll sync group {}", group_id);
5916 } else {
5917 tracing::warn!("Scroll sync group {} not found", group_id);
5918 }
5919 }
5920
5921 fn handle_create_buffer_group(
5922 &mut self,
5923 name: String,
5924 mode: String,
5925 layout_json: String,
5926 request_id: Option<u64>,
5927 ) {
5928 match self.create_buffer_group(name, mode, layout_json) {
5929 Ok(result) => {
5930 if let Some(req_id) = request_id {
5931 let json = serde_json::to_string(&result).unwrap_or_default();
5932 self.plugin_manager
5933 .read()
5934 .unwrap()
5935 .resolve_callback(fresh_core::api::JsCallbackId::from(req_id), json);
5936 }
5937 }
5938 Err(e) => {
5939 tracing::error!("Failed to create buffer group: {}", e);
5940 }
5941 }
5942 }
5943
5944 fn handle_send_terminal_input(
5945 &mut self,
5946 terminal_id: crate::services::terminal::TerminalId,
5947 data: String,
5948 ) {
5949 if let Some(handle) = self.active_window().terminal_manager.get(terminal_id) {
5950 handle.write(data.as_bytes());
5951 tracing::trace!(
5952 "Plugin sent {} bytes to terminal {:?}",
5953 data.len(),
5954 terminal_id
5955 );
5956 } else {
5957 tracing::warn!(
5958 "Plugin tried to send input to non-existent terminal {:?}",
5959 terminal_id
5960 );
5961 }
5962 }
5963
5964 fn handle_close_terminal(&mut self, terminal_id: crate::services::terminal::TerminalId) {
5965 let buffer_to_close = self
5966 .active_window()
5967 .terminal_buffers
5968 .iter()
5969 .find(|(_, &tid)| tid == terminal_id)
5970 .map(|(&bid, _)| bid);
5971 if let Some(buffer_id) = buffer_to_close {
5972 if let Err(e) = self.close_buffer(buffer_id) {
5973 tracing::warn!("Failed to close terminal buffer: {}", e);
5974 }
5975 tracing::info!("Plugin closed terminal {:?}", terminal_id);
5976 } else {
5977 self.active_window_mut().terminal_manager.close(terminal_id);
5978 tracing::info!("Plugin closed terminal {:?} (no buffer found)", terminal_id);
5979 }
5980 }
5981
5982 fn handle_signal_window(&mut self, id: fresh_core::WindowId, signal: &str) {
5990 let Some(window) = self.windows.get_mut(&id) else {
5991 tracing::warn!("Plugin SignalWindow targeted unknown window {:?}", id);
5992 return;
5993 };
5994 let results = window.process_groups.signal_all(signal);
5995 for (entry, result) in results {
5996 match result {
5997 Ok(true) => tracing::info!(
5998 "SignalWindow {:?}: {} → pid {} ({})",
5999 id,
6000 signal,
6001 entry.leader_pid,
6002 entry.label
6003 ),
6004 Ok(false) => tracing::debug!(
6005 "SignalWindow {:?}: pid {} ({}) already exited",
6006 id,
6007 entry.leader_pid,
6008 entry.label
6009 ),
6010 Err(e) => tracing::warn!(
6011 "SignalWindow {:?}: pid {} ({}): {}",
6012 id,
6013 entry.leader_pid,
6014 entry.label,
6015 e
6016 ),
6017 }
6018 }
6019 }
6020}
6021
6022#[cfg(test)]
6023mod tests {
6024 use tokio::io::{AsyncReadExt, BufReader};
6037 use tokio::process::Command as TokioCommand;
6038 use tokio::time::{timeout, Duration};
6039
6040 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6051 async fn kill_via_oneshot_terminates_long_running_child() {
6052 let mut cmd = TokioCommand::new("sleep");
6053 cmd.args(["30"]);
6054 cmd.stdout(std::process::Stdio::piped());
6055 cmd.stderr(std::process::Stdio::piped());
6056
6057 let mut child = cmd.spawn().expect("spawn sh -c sleep 30");
6058 let pid = child.id().expect("child has a pid");
6059
6060 let (kill_tx, mut kill_rx) = tokio::sync::oneshot::channel::<()>();
6061 let stdout_pipe = child.stdout.take();
6062 let stderr_pipe = child.stderr.take();
6063
6064 let stdout_fut = async {
6065 let mut buf = String::new();
6066 if let Some(s) = stdout_pipe {
6067 #[allow(clippy::let_underscore_must_use)]
6068 let _ = BufReader::new(s).read_to_string(&mut buf).await;
6069 }
6070 buf
6071 };
6072 let stderr_fut = async {
6073 let mut buf = String::new();
6074 if let Some(s) = stderr_pipe {
6075 #[allow(clippy::let_underscore_must_use)]
6076 let _ = BufReader::new(s).read_to_string(&mut buf).await;
6077 }
6078 buf
6079 };
6080 let wait_fut = async {
6081 tokio::select! {
6082 status = child.wait() => {
6083 status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1)
6084 }
6085 _ = &mut kill_rx => {
6086 #[allow(clippy::let_underscore_must_use)]
6087 let _ = child.start_kill();
6088 child
6089 .wait()
6090 .await
6091 .map(|s| s.code().unwrap_or(-1))
6092 .unwrap_or(-1)
6093 }
6094 }
6095 };
6096
6097 tokio::time::sleep(Duration::from_millis(50)).await;
6102 kill_tx.send(()).expect("kill channel send");
6103
6104 let result = timeout(Duration::from_secs(5), async {
6105 tokio::join!(stdout_fut, stderr_fut, wait_fut)
6106 })
6107 .await;
6108
6109 let (_stdout, _stderr, exit_code) = result.expect(
6110 "kill path must resolve within 5s — if this times out the \
6111 select! arm order or kill-then-wait logic is broken",
6112 );
6113 assert_ne!(
6125 exit_code, 0,
6126 "killed child must exit non-success (got 0 — did the \
6127 kill arm fire too late, or did sleep somehow complete?)"
6128 );
6129
6130 #[cfg(unix)]
6139 {
6140 let still_alive = std::process::Command::new("kill")
6141 .args(["-0", &pid.to_string()])
6142 .status()
6143 .map(|s| s.success())
6144 .unwrap_or(false);
6145 assert!(
6146 !still_alive,
6147 "process {pid} must be reaped after wait() — a still-\
6148 alive check means the kill path leaked the child"
6149 );
6150 }
6151 #[cfg(not(unix))]
6152 {
6153 let _ = pid;
6156 }
6157 }
6158}
6159
6160impl Window {
6161 #[cfg(feature = "plugins")]
6176 pub(crate) fn populate_plugin_state_snapshot(
6177 &mut self,
6178 snapshot: &mut fresh_core::api::EditorStateSnapshot,
6179 ) {
6180 use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
6181
6182 let current_gen = self.resources.grammar_registry.catalog_gen();
6188 if snapshot.last_grammar_gen != current_gen {
6189 snapshot.available_grammars = self
6190 .resources
6191 .grammar_registry
6192 .available_grammar_info()
6193 .into_iter()
6194 .map(|g| fresh_core::api::GrammarInfoSnapshot {
6195 name: g.name,
6196 source: g.source.to_string(),
6197 file_extensions: g.file_extensions,
6198 short_name: g.short_name,
6199 })
6200 .collect();
6201 snapshot.last_grammar_gen = current_gen;
6202 }
6203
6204 snapshot.active_buffer_id = self.active_buffer();
6205
6206 let (mgr_ref, vs_ref) = self
6207 .buffers
6208 .splits()
6209 .expect("active window must have a populated split layout");
6210 let active_split = mgr_ref.active_split();
6211 snapshot.active_split_id = active_split.0 .0;
6212
6213 snapshot.buffers.clear();
6215 snapshot.buffer_saved_diffs.clear();
6216 snapshot.buffer_cursor_positions.clear();
6217 snapshot.buffer_text_properties.clear();
6218
6219 let active_vs_opt = vs_ref.get(&active_split);
6220 for (buffer_id, state) in &self.buffers {
6221 let is_virtual = self
6222 .buffer_metadata
6223 .get(buffer_id)
6224 .map(|m| m.is_virtual())
6225 .unwrap_or(false);
6226 let view_mode = active_vs_opt
6231 .and_then(|vs| vs.buffer_state(*buffer_id))
6232 .map(|bs| match bs.view_mode {
6233 crate::state::ViewMode::Source => "source",
6234 crate::state::ViewMode::PageView => "compose",
6235 })
6236 .unwrap_or("source");
6237 let compose_width = active_vs_opt
6238 .and_then(|vs| vs.buffer_state(*buffer_id))
6239 .and_then(|bs| bs.compose_width);
6240 let is_composing_in_any_split = vs_ref.values().any(|vs| {
6241 vs.buffer_state(*buffer_id)
6242 .map(|bs| matches!(bs.view_mode, crate::state::ViewMode::PageView))
6243 .unwrap_or(false)
6244 });
6245 let is_preview = self
6246 .buffer_metadata
6247 .get(buffer_id)
6248 .map(|m| m.is_preview)
6249 .unwrap_or(false);
6250 let splits: Vec<fresh_core::SplitId> = mgr_ref
6256 .splits_for_buffer(*buffer_id)
6257 .into_iter()
6258 .map(|leaf_id| leaf_id.0)
6259 .collect();
6260 let buffer_info = BufferInfo {
6261 id: *buffer_id,
6262 path: state.buffer.file_path().map(|p| p.to_path_buf()),
6263 modified: state.buffer.is_modified(),
6264 length: state.buffer.len(),
6265 is_virtual,
6266 view_mode: view_mode.to_string(),
6267 is_composing_in_any_split,
6268 compose_width,
6269 language: state.language.clone(),
6270 is_preview,
6271 splits,
6272 };
6273 snapshot.buffers.insert(*buffer_id, buffer_info);
6274
6275 let diff = {
6276 let diff = state.buffer.diff_since_saved();
6277 BufferSavedDiff {
6278 equal: diff.equal,
6279 byte_ranges: diff.byte_ranges.clone(),
6280 }
6281 };
6282 snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
6283
6284 let is_hidden = self
6293 .buffer_metadata
6294 .get(buffer_id)
6295 .is_some_and(|m| m.hidden_from_tabs);
6296 let source_split = vs_ref.iter().find(|(split_id, vs)| {
6297 vs.keyed_states.contains_key(buffer_id)
6298 && !(is_hidden && self.grouped_subtrees.contains_key(split_id))
6299 });
6300 let cursor_pos = source_split
6301 .and_then(|(_, vs)| vs.buffer_state(*buffer_id))
6302 .map(|bs| bs.cursors.primary().position)
6303 .unwrap_or(0);
6304 tracing::trace!(
6305 "snapshot: buffer {:?} cursor_pos={} (from split {:?})",
6306 buffer_id,
6307 cursor_pos,
6308 source_split.map(|(id, _)| *id),
6309 );
6310 snapshot
6311 .buffer_cursor_positions
6312 .insert(*buffer_id, cursor_pos);
6313
6314 if !state.text_properties.is_empty() {
6316 snapshot
6317 .buffer_text_properties
6318 .insert(*buffer_id, state.text_properties.all().to_vec());
6319 }
6320 }
6321
6322 let active_buf_id = snapshot.active_buffer_id;
6333 let active_split_id = self.effective_active_pair().0;
6334 self.buffers
6335 .with_all_mut(|buffers_mut, mgr, vs_map| {
6336 let _ = mgr; if let Some(active_vs) = vs_map.get(&active_split_id) {
6338 let active_cursors = &active_vs.cursors;
6340 let primary = active_cursors.primary();
6341 let primary_position = primary.position;
6342 let primary_selection = primary.selection_range();
6343
6344 snapshot.primary_cursor = Some(CursorInfo {
6345 position: primary_position,
6346 selection: primary_selection.clone(),
6347 });
6348
6349 snapshot.all_cursors = active_cursors
6350 .iter()
6351 .map(|(_, cursor)| CursorInfo {
6352 position: cursor.position,
6353 selection: cursor.selection_range(),
6354 })
6355 .collect();
6356
6357 if let Some(range) = primary_selection {
6359 if let Some(active_state) = buffers_mut.get_mut(&active_buf_id) {
6360 snapshot.selected_text =
6361 Some(active_state.get_text_range(range.start, range.end));
6362 }
6363 }
6364
6365 let top_line = buffers_mut.get(&active_buf_id).and_then(|state| {
6367 if state.buffer.line_count().is_some() {
6368 Some(state.buffer.get_line_number(active_vs.viewport.top_byte))
6369 } else {
6370 None
6371 }
6372 });
6373 snapshot.viewport = Some(ViewportInfo {
6374 top_byte: active_vs.viewport.top_byte,
6375 top_line,
6376 left_column: active_vs.viewport.left_column,
6377 width: active_vs.viewport.width,
6378 height: active_vs.viewport.height,
6379 });
6380 } else {
6381 snapshot.primary_cursor = None;
6382 snapshot.all_cursors.clear();
6383 snapshot.viewport = None;
6384 snapshot.selected_text = None;
6385 }
6386
6387 snapshot.splits.clear();
6389 for (leaf_id, vs) in vs_map.iter() {
6390 let buf_id = vs.active_buffer;
6391 let top_line = buffers_mut.get(&buf_id).and_then(|state| {
6392 if state.buffer.line_count().is_some() {
6393 Some(state.buffer.get_line_number(vs.viewport.top_byte))
6394 } else {
6395 None
6396 }
6397 });
6398 snapshot.splits.push(fresh_core::api::SplitSnapshot {
6399 split_id: leaf_id.0 .0,
6400 buffer_id: buf_id,
6401 viewport: ViewportInfo {
6402 top_byte: vs.viewport.top_byte,
6403 top_line,
6404 left_column: vs.viewport.left_column,
6405 width: vs.viewport.width,
6406 height: vs.viewport.height,
6407 },
6408 });
6409 }
6410 })
6411 .expect("active window must have a populated split layout");
6412
6413 snapshot.active_session_plugin_states = self.plugin_state.clone();
6419 snapshot.diagnostics = Arc::clone(&self.stored_diagnostics);
6424 snapshot.folding_ranges = Arc::clone(&self.stored_folding_ranges);
6425
6426 snapshot.editor_mode = self.editor_mode.clone();
6428
6429 let active_split_id_u64 = active_split_id.0 .0;
6434 let split_changed = snapshot.plugin_view_states_split != active_split_id_u64;
6435 if split_changed {
6436 snapshot.plugin_view_states.clear();
6437 snapshot.plugin_view_states_split = active_split_id_u64;
6438 }
6439
6440 {
6442 let open_bids: Vec<_> = snapshot.buffers.keys().copied().collect();
6443 snapshot
6444 .plugin_view_states
6445 .retain(|bid, _| open_bids.contains(bid));
6446 }
6447
6448 if let Some(vs_map) = self.buffers.split_view_states() {
6450 if let Some(active_vs) = vs_map.get(&active_split_id) {
6451 for (buffer_id, buf_state) in &active_vs.keyed_states {
6452 if !buf_state.plugin_state.is_empty() {
6453 let entry = snapshot.plugin_view_states.entry(*buffer_id).or_default();
6454 for (key, value) in &buf_state.plugin_state {
6455 entry.entry(key.clone()).or_insert_with(|| value.clone());
6456 }
6457 }
6458 }
6459 }
6460 }
6461 }
6462}
6463
6464const HTTP_FETCH_MAX_BYTES: u64 = 64 * 1024 * 1024;
6468
6469fn fetch_url_to_file(url: &str, target: &std::path::Path) -> Result<u16, String> {
6475 let tls_config = ureq::tls::TlsConfig::builder()
6479 .root_certs(ureq::tls::RootCerts::PlatformVerifier)
6480 .build();
6481
6482 let agent = ureq::Agent::config_builder()
6483 .timeout_global(Some(std::time::Duration::from_secs(30)))
6484 .http_status_as_error(false)
6485 .tls_config(tls_config)
6486 .build()
6487 .new_agent();
6488
6489 let response = agent
6490 .get(url)
6491 .header("User-Agent", "fresh-editor")
6492 .call()
6493 .map_err(|e| format!("HTTP request failed: {}", e))?;
6494
6495 let status = response.status().as_u16();
6496 if !(200..300).contains(&status) {
6497 return Ok(status);
6498 }
6499
6500 let mut file = std::fs::File::create(target)
6501 .map_err(|e| format!("failed to create {}: {}", target.display(), e))?;
6502
6503 let mut reader = response
6504 .into_body()
6505 .into_with_config()
6506 .limit(HTTP_FETCH_MAX_BYTES)
6507 .reader();
6508
6509 std::io::copy(&mut reader, &mut file)
6510 .map_err(|e| format!("failed to write response body: {}", e))?;
6511
6512 Ok(status)
6513}