diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-03-05 21:13:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-05 21:13:02 +0100 |
| commit | 903cdf5d32476a8d6cc208d2c418a1f2c67e5a53 (patch) | |
| tree | 9a4647d9dc3efcdea8149fd78fdfa956b3c8d8cb /zellij-server/src/tab/unit/tab_integration_tests.rs | |
| parent | build: make test mirror build command for plugins (#4801) (diff) | |
| download | sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.gz sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.xz sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.zip | |
Click file paths to open in editor, allow plugins to change styles and hover of viewport (#4802)
* initial implementation
* implement hover
* add styles and foreground/background emphases, fix upsert hover collisions
* initial implementation of link plugin
* suppress highlighting + clicks on unfocused and mouse mode panes
* also make italic
* fix env var and ~ expansions
* fix regex issues and add defensive validations
* fix tests and add plugin assets
* add tests
* refactor all the things
* add pr
Diffstat (limited to 'zellij-server/src/tab/unit/tab_integration_tests.rs')
| -rw-r--r-- | zellij-server/src/tab/unit/tab_integration_tests.rs | 467 |
1 files changed, 467 insertions, 0 deletions
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs index bcd83279..ca68538c 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -12460,3 +12460,470 @@ fn in_place_pane_with_close_replaced_pane_true_closes_original() { // Snapshot shows only pane 1 (pane 2 was permanently closed, not restored) assert_snapshot!(snapshot); } + +// ===================================================================== +// Plugin Highlight Mouse Integration Tests +// ===================================================================== + +use zellij_utils::data::{HighlightStyle, RegexHighlight}; + +fn create_new_tab_with_plugin_receiver( + size: Size, + default_mode: ModeInfo, +) -> (Tab, Receiver<(PluginInstruction, ErrorContext)>) { + set_session_name("test".into()); + let index = 0; + let position = 0; + let name = String::new(); + let os_api = Box::new(FakeInputOutput::default()); + let mut senders = ThreadSenders::default().silently_fail_on_send(); + let (mock_plugin_sender, mock_plugin_receiver): ChannelWithContext<PluginInstruction> = + channels::unbounded(); + senders.replace_to_plugin(SenderWithContext::new(mock_plugin_sender)); + let max_panes = None; + let mode_info = default_mode; + let style = Style::default(); + let draw_pane_frames = true; + let auto_layout = true; + let client_id = 1; + let session_is_mirrored = true; + let mut connected_clients = HashMap::new(); + connected_clients.insert(client_id, false); + let connected_clients = Rc::new(RefCell::new(connected_clients)); + let character_cell_info = Rc::new(RefCell::new(None)); + let stacked_resize = Rc::new(RefCell::new(true)); + let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default())); + let copy_options = CopyOptions::default(); + let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new())); + let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default())); + let current_group = Rc::new(RefCell::new(PaneGroups::new(ThreadSenders::default()))); + let currently_marking_pane_group = Rc::new(RefCell::new(HashMap::new())); + let debug = false; + let arrow_fonts = true; + let styled_underlines = true; + let osc8_hyperlinks = true; + let explicitly_disable_kitty_keyboard_protocol = false; + let advanced_mouse_actions = true; + let web_sharing = WebSharing::Off; + let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let web_server_port = 8080; + let mut tab = Tab::new( + index, + position, + name, + size, + character_cell_info, + stacked_resize, + sixel_image_store, + os_api, + senders, + max_panes, + style, + mode_info, + draw_pane_frames, + auto_layout, + connected_clients, + session_is_mirrored, + Some(client_id), + copy_options, + terminal_emulator_colors, + terminal_emulator_color_codes, + (vec![], vec![]), + PathBuf::from("my_default_shell"), + debug, + arrow_fonts, + styled_underlines, + osc8_hyperlinks, + explicitly_disable_kitty_keyboard_protocol, + None, + false, + web_sharing, + current_group, + currently_marking_pane_group, + advanced_mouse_actions, + true, // mouse_hover_effects + web_server_ip, + web_server_port, + ); + tab.apply_layout( + TiledPaneLayout::default(), + vec![], + vec![(1, None)], + vec![], + HashMap::new(), + client_id, + None, + ) + .unwrap(); + (tab, mock_plugin_receiver) +} + +#[test] +fn click_on_plugin_highlight_sends_highlight_clicked() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let (mut tab, mock_plugin_receiver) = + create_new_tab_with_plugin_receiver(size, ModeInfo::default()); + + // Feed PTY bytes to pane 1 + tab.handle_pty_bytes(1, Vec::from("click here foo bar".as_bytes())) + .unwrap(); + + // Set plugin highlight on pane 1 for pattern "foo" + let highlights = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane( + PaneId::Terminal(1), + 42, + highlights, + &Style::default(), + ); + + // Left-click at position where "foo" appears + // With draw_pane_frames=true, content starts at row 1, col 1 + // "click here foo bar" -> "foo" starts at offset 11, so col = 1 + 11 = 12 + let click_position = Position::new(1, 12); + let effect = tab + .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id) + .unwrap(); + + assert!(effect.state_changed); + + // Check that HighlightClicked was sent to the plugin channel + let mut found_highlight_clicked = false; + while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() { + if let PluginInstruction::HighlightClicked { + plugin_id, + pane_id, + pattern, + matched_string, + .. + } = instruction + { + assert_eq!(plugin_id, 42); + assert_eq!(pane_id, PaneId::Terminal(1)); + assert_eq!(pattern, "foo"); + assert_eq!(matched_string, "foo"); + found_highlight_clicked = true; + break; + } + } + assert!( + found_highlight_clicked, + "Expected HighlightClicked instruction to be sent" + ); +} + +#[test] +fn click_outside_highlight_starts_normal_selection() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let (mut tab, mock_plugin_receiver) = + create_new_tab_with_plugin_receiver(size, ModeInfo::default()); + + tab.handle_pty_bytes(1, Vec::from("click here foo bar".as_bytes())) + .unwrap(); + + let highlights = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane( + PaneId::Terminal(1), + 42, + highlights, + &Style::default(), + ); + + // Click on "click" (col 1), not on "foo" + let click_position = Position::new(1, 1); + let _effect = tab + .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id) + .unwrap(); + + // No HighlightClicked should be sent + let mut found_highlight_clicked = false; + while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() { + if matches!(instruction, PluginInstruction::HighlightClicked { .. }) { + found_highlight_clicked = true; + } + } + assert!( + !found_highlight_clicked, + "HighlightClicked should NOT be sent for miss" + ); + + // Selection should have started + assert!(tab.selecting_with_mouse_in_pane.is_some()); +} + +#[test] +fn hover_over_highlight_shows_styling() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + let mut output = Output::default(); + + tab.handle_pty_bytes(1, Vec::from("hello link_text bar".as_bytes())) + .unwrap(); + + let highlights = vec![RegexHighlight { + pattern: "link_text".into(), + style: HighlightStyle::None, + context: BTreeMap::new(), + on_hover: true, + bold: false, + italic: true, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, highlights, &Style::default()); + + // Hover over the match (link_text starts at offset 6, with frame offset col = 7) + let hover_position = Position::new(1, 9); + let _effect = tab + .handle_mouse_event( + &MouseEvent::new_buttonless_motion(hover_position), + client_id, + ) + .unwrap(); + + tab.render(&mut output, None).unwrap(); + let snapshot_with_hover = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot_with_hover); + + // Move hover away from the match + let mut output2 = Output::default(); + let far_position = Position::new(1, 1); + let _effect2 = tab + .handle_mouse_event(&MouseEvent::new_buttonless_motion(far_position), client_id) + .unwrap(); + + tab.render(&mut output2, None).unwrap(); + let snapshot_without_hover = take_snapshot( + output2.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot_without_hover); +} + +#[test] +fn hover_on_unfocused_pane_no_highlight() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + let new_pane_id = PaneId::Terminal(2); + let mut output = Output::default(); + + // Create a vertical split + tab.vertical_split(new_pane_id, None, client_id, None, None) + .unwrap(); + + tab.handle_pty_bytes(1, Vec::from("link_text".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(2, Vec::from("right pane".as_bytes())) + .unwrap(); + + // Active pane is pane 2 (right) + assert_eq!(tab.get_active_pane_id(client_id), Some(PaneId::Terminal(2))); + + // Set on_hover highlight on pane 1 (left, unfocused) + let highlights = vec![RegexHighlight { + pattern: "link_text".into(), + style: HighlightStyle::None, + context: BTreeMap::new(), + on_hover: true, + bold: false, + italic: true, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, highlights, &Style::default()); + + // Hover over left pane (unfocused) + let hover_position = Position::new(1, 5); + let _effect = tab + .handle_mouse_event( + &MouseEvent::new_buttonless_motion(hover_position), + client_id, + ) + .unwrap(); + + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + // Hover highlight should NOT be visible on the unfocused pane + assert_snapshot!(snapshot); +} + +#[test] +fn highlight_click_in_mouse_mode_pane_suppressed() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let (mut tab, mock_plugin_receiver) = + create_new_tab_with_plugin_receiver(size, ModeInfo::default()); + + // Feed PTY bytes that enable mouse tracking (SGR mode + any-event tracking) + tab.handle_pty_bytes(1, Vec::from("\x1b[?1006h\x1b[?1003h".as_bytes())) + .unwrap(); + // Then feed text with matchable content + tab.handle_pty_bytes(1, Vec::from("foo bar baz".as_bytes())) + .unwrap(); + + // Set plugin highlight + let highlights = vec![RegexHighlight { + pattern: "foo".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane( + PaneId::Terminal(1), + 42, + highlights, + &Style::default(), + ); + + // Left-click on the highlight position + // With mouse tracking, the click goes through SendToTerminal, not StartSelection + let click_position = Position::new(1, 1); + let _effect = tab + .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id) + .unwrap(); + + // No HighlightClicked should be sent (the click is forwarded to the terminal) + let mut found_highlight_clicked = false; + while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() { + if matches!(instruction, PluginInstruction::HighlightClicked { .. }) { + found_highlight_clicked = true; + } + } + assert!( + !found_highlight_clicked, + "HighlightClicked should NOT be sent when terminal has mouse tracking" + ); +} + +#[test] +fn set_and_clear_highlights_across_tiled_and_floating() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut tab = create_new_tab(size, ModeInfo::default()); + let pane2 = PaneId::Terminal(2); + let pane3 = PaneId::Terminal(3); + + // Create a second tiled pane + tab.vertical_split(pane2, None, client_id, None, None) + .unwrap(); + + // Create a floating pane + tab.toggle_floating_panes(Some(client_id), None, None) + .unwrap(); + tab.new_pane( + pane3, + None, + None, + false, + true, + NewPanePlacement::default(), + Some(client_id), + None, + ) + .unwrap(); + + // Feed content to all panes + tab.handle_pty_bytes(1, Vec::from("aaa bbb".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(2, Vec::from("aaa bbb".as_bytes())) + .unwrap(); + tab.handle_pty_bytes(3, Vec::from("aaa bbb".as_bytes())) + .unwrap(); + + // Set highlights from plugin 1 (pattern "aaa") on all panes + let h1 = vec![RegexHighlight { + pattern: "aaa".into(), + style: HighlightStyle::Emphasis0, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: true, + }]; + tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, h1.clone(), &Style::default()); + tab.set_plugin_regex_highlights_for_pane(pane2, 1, h1.clone(), &Style::default()); + tab.set_plugin_regex_highlights_for_pane(pane3, 1, h1.clone(), &Style::default()); + + // Set highlights from plugin 2 (pattern "bbb") on all panes + let h2 = vec![RegexHighlight { + pattern: "bbb".into(), + style: HighlightStyle::Emphasis1, + context: BTreeMap::new(), + on_hover: false, + bold: false, + italic: false, + underline: false, + }]; + tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 2, h2.clone(), &Style::default()); + tab.set_plugin_regex_highlights_for_pane(pane2, 2, h2.clone(), &Style::default()); + tab.set_plugin_regex_highlights_for_pane(pane3, 2, h2.clone(), &Style::default()); + + // Clear plugin 1 highlights across all panes + tab.clear_all_plugin_highlights(1); + + // Verify plugin 2 highlights remain — we can use the tab render to snapshot, + // or directly check that plugin 2 pattern still matches on pane 1 + // Since clear_all_plugin_highlights iterates all panes, we test via + // the set_plugin_regex_highlights_for_pane / clear flow: + // Plugin 2 "bbb" highlights should still be active. + let mut output = Output::default(); + tab.render(&mut output, None).unwrap(); + let snapshot = take_snapshot( + output.serialize().unwrap().get(&client_id).unwrap(), + size.rows, + size.cols, + Palette::default(), + ); + assert_snapshot!(snapshot); +} |
