diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-07 01:19:39 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-07 01:20:13 +0300 |
| commit | 17e2b2b878ca9b8cad60fd26baef549479c13081 (patch) | |
| tree | afc5947934ee3dd382805952605224de4c77ce13 /src | |
| parent | 4f22e9a73ba4d9029468e078235c5f5ce629d179 (diff) | |
fix: honor prefix in copy mode
refs #681
Based on PR #1092 by @reobin.
Co-authored-by: Robin Gagnon <me@reobin.dev>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 32 | ||||
| -rw-r--r-- | src/app/agents.rs | 2 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 99 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 2 | ||||
| -rw-r--r-- | src/app/api/tabs.rs | 4 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 4 | ||||
| -rw-r--r-- | src/app/input/copy_mode.rs | 315 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 14 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 51 | ||||
| -rw-r--r-- | src/ui/panes.rs | 9 |
10 files changed, 496 insertions, 36 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 52ebbbb7..12c0df87 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -297,6 +297,14 @@ impl AppState { } } + fn sync_selection_after_focus_navigation(&mut self) { + if self.copy_mode.is_some() { + self.sync_copy_mode_with_focus(); + } else { + self.clear_selection(); + } + } + pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(ws) = self.workspaces.get(ws_idx) else { return false; @@ -313,6 +321,9 @@ impl AppState { return false; } + if self.copy_mode.is_some() { + self.clear_copy_mode_selection(); + } self.switch_workspace_tab(ws_idx, tab_idx); if let Some(tab) = self .workspaces @@ -322,6 +333,7 @@ impl AppState { tab.layout.focus_pane(pane_id); self.previous_pane_focus = previous; self.mark_session_dirty(); + self.sync_copy_mode_with_focus(); return true; } false @@ -949,8 +961,6 @@ impl AppState { pub fn switch_workspace(&mut self, idx: usize) { if idx < self.workspaces.len() { let previous_focus = self.current_pane_focus_target(); - self.selection = None; - self.selection_autoscroll = None; self.active = Some(idx); self.selected = idx; let workspace_id = self.workspaces[idx].id.clone(); @@ -967,6 +977,7 @@ impl AppState { self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); + self.sync_selection_after_focus_navigation(); } } @@ -984,8 +995,6 @@ impl AppState { let previous_focus = self.current_pane_focus_target(); let workspace_changed = self.active != Some(ws_idx); - self.selection = None; - self.selection_autoscroll = None; self.active = Some(ws_idx); self.selected = ws_idx; let workspace_id = self.workspaces[ws_idx].id.clone(); @@ -1003,6 +1012,7 @@ impl AppState { self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); + self.sync_selection_after_focus_navigation(); true } @@ -1089,8 +1099,6 @@ impl AppState { pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { let previous_focus = self.current_pane_focus_target(); - self.selection = None; - self.selection_autoscroll = None; let Some(ws) = self.workspaces.get_mut(ws_idx) else { return; }; @@ -1102,6 +1110,7 @@ impl AppState { self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); + self.sync_selection_after_focus_navigation(); } } @@ -1429,6 +1438,15 @@ impl AppState { &mut self, pane_ids: impl IntoIterator<Item = PaneId>, ) { + let pane_ids = pane_ids.into_iter().collect::<Vec<_>>(); + self.clear_copy_mode_for_removed_panes(pane_ids.iter().copied()); + if self + .previous_pane_focus + .as_ref() + .is_some_and(|focus| pane_ids.contains(&focus.pane_id)) + { + self.previous_pane_focus = None; + } for pane_id in pane_ids { self.plugin_panes.remove(&pane_id); } @@ -2992,7 +3010,7 @@ impl AppState { fn handle_pane_died(&mut self, pane_id: PaneId) { self.pending_agent_notifications.remove(&pane_id); - self.plugin_panes.remove(&pane_id); + self.remove_plugin_pane_records([pane_id]); let ws_idx = self .workspaces .iter() diff --git a/src/app/agents.rs b/src/app/agents.rs index 262bb0e4..0a03901d 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -38,7 +38,7 @@ impl App { let resolved = self.resolve_terminal_target(target)?; self.state .focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id); - self.state.mode = Mode::Terminal; + self.state.settle_terminal_mode_after_focus(); self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| TerminalTargetError::NotFound { target: target.to_string(), diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 60b2e93b..14c62942 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -14,7 +14,9 @@ use crate::api::schema::{ PaneZoomResult, ReadFormat, ReadSource, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; -use crate::app::{App, Mode}; +use crate::app::App; +#[cfg(test)] +use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ @@ -102,7 +104,7 @@ impl App { self.state.switch_workspace_tab(ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); - self.state.mode = Mode::Terminal; + self.state.settle_terminal_mode_after_focus(); } self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); @@ -164,7 +166,7 @@ impl App { }; self.state.focus_pane_in_workspace(ws_idx, pane_id); - self.state.mode = Mode::Terminal; + self.state.settle_terminal_mode_after_focus(); let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); @@ -356,7 +358,7 @@ impl App { if let Some(target_pane_id) = target { self.state.focus_pane_in_workspace(ws_idx, target_pane_id); self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.mode = Mode::Terminal; + self.state.settle_terminal_mode_after_focus(); } let focused_pane_id = self .state @@ -947,7 +949,7 @@ impl App { .switch_workspace_tab(target_ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id); - self.state.mode = Mode::Terminal; + self.state.settle_terminal_mode_after_focus(); } let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx)); let created_tab = if created_tab { @@ -1104,7 +1106,7 @@ impl App { if outcome.changed || outcome.focus_changed { self.schedule_session_save(); } - self.state.mode = Mode::Terminal; + self.state.settle_terminal_mode_after_focus(); let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; @@ -1485,7 +1487,7 @@ impl App { }; ws.close_pane(pane_id) }; - self.state.plugin_panes.remove(&pane_id); + self.state.remove_plugin_pane_records([pane_id]); if should_close_workspace { self.state.selected = ws_idx; self.state.close_selected_workspace(); @@ -2456,6 +2458,48 @@ mod tests { } #[test] + fn api_pane_move_focuses_copy_mode_pane_back_into_copy_mode() { + let mut app = app_with_linked_worktree(); + let source = app.state.workspaces[0].tabs[0].root_pane; + let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); + let target = app.state.workspaces[0].tabs[target_tab].root_pane; + seed_terminal_states(&mut app); + app.state.copy_mode = Some(crate::app::state::CopyModeState { + pane_id: source, + cursor_row: 0, + cursor_col: 0, + entry_offset_from_bottom: 0, + selection: None, + }); + let source_public = app.public_pane_id(0, source).unwrap(); + let target_public = app.public_pane_id(0, target).unwrap(); + let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); + + let response = app.handle_pane_move( + "req".into(), + PaneMoveParams { + pane_id: source_public, + destination: PaneMoveDestination::Tab { + tab_id: target_tab_public, + target_pane_id: Some(target_public), + split: SplitDirection::Right, + ratio: None, + }, + focus: true, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneMove { move_result } = success.result else { + panic!("expected pane move response"); + }; + assert!(move_result.changed); + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, source); + assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source); + } + + #[test] fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() { let mut app = app_with_linked_worktree(); app.state.workspaces.push(Workspace::test_new("other")); @@ -3044,6 +3088,47 @@ mod tests { } #[test] + fn api_pane_zoom_focuses_copy_mode_pane_back_into_copy_mode() { + let mut app = app_with_linked_worktree(); + app.state.workspaces.push(Workspace::test_new("other")); + let source = app.state.workspaces[0].tabs[0].root_pane; + let target = app.state.workspaces[1].tabs[0].root_pane; + let _other = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + let _target_other = + app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); + app.state.workspaces[1].tabs[0].layout.focus_pane(target); + app.state.active = Some(1); + app.state.selected = 1; + app.state.mode = Mode::Terminal; + app.state.copy_mode = Some(crate::app::state::CopyModeState { + pane_id: source, + cursor_row: 0, + cursor_col: 0, + entry_offset_from_bottom: 0, + selection: None, + }); + let source_public = app.public_pane_id(0, source).unwrap(); + + let response = app.handle_pane_zoom( + "req".into(), + PaneZoomParams { + pane_id: Some(source_public), + mode: PaneZoomMode::On, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(zoom.focus_changed); + assert_eq!(app.state.active, Some(0)); + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); + assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); + } + + #[test] fn api_pane_zoom_single_pane_returns_noop() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index ec0a5d64..b77b4715 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -405,7 +405,7 @@ impl App { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); } self.state.focus_pane_in_workspace(ws_idx, pane_id); - self.state.mode = crate::app::Mode::Terminal; + self.state.settle_terminal_mode_after_focus(); let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 1f29f54b..46487cfa 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -248,9 +248,7 @@ impl App { format!("tab {} could not be closed", target.tab_id), ); } - for pane_id in pane_ids { - self.state.plugin_panes.remove(&pane_id); - } + self.state.remove_plugin_pane_records(pane_ids); self.state.remove_unattached_terminal_ids(terminal_ids); self.shutdown_detached_terminal_runtimes(); self.schedule_session_save(); diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 19e4f6ae..8899ec44 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -175,9 +175,7 @@ impl App { .unwrap_or_default(); self.state.selected = index; self.state.close_selected_workspace(); - for pane_id in pane_ids { - self.state.plugin_panes.remove(&pane_id); - } + self.state.remove_plugin_pane_records(pane_ids); self.shutdown_detached_terminal_runtimes(); self.emit_event(EventEnvelope { event: EventKind::WorkspaceClosed, diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index b2219d9f..6ced6409 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -17,6 +17,10 @@ impl App { return; } self.state.update_dismissed = true; + if self.state.is_prefix_key(key) { + self.state.mode = Mode::Prefix; + return; + } self.state .handle_copy_mode_key(&self.terminal_runtimes, key); if let Some(content) = self.state.request_clipboard_write.take() { @@ -167,6 +171,10 @@ impl AppState { } } + pub(crate) fn cancel_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { + self.exit_copy_mode(terminal_runtimes, false); + } + fn exit_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, copy: bool) { let restore_scroll = self .copy_mode @@ -497,6 +505,70 @@ impl AppState { .extract_selection(&row_selection) } + pub(crate) fn copy_mode_pane_is_focused(&self) -> bool { + let Some(copy_mode) = self.copy_mode else { + return false; + }; + self.current_pane_focus_target() + .is_some_and(|target| target.pane_id == copy_mode.pane_id) + } + + pub(crate) fn sync_copy_mode_with_focus(&mut self) { + if self.copy_mode.is_none() { + return; + } + if !matches!( + self.mode, + Mode::Copy | Mode::Terminal | Mode::Navigate | Mode::Prefix + ) { + return; + } + if self.copy_mode_pane_is_focused() { + self.mode = Mode::Copy; + } else if self.active.is_some() { + self.clear_copy_mode_selection(); + self.mode = Mode::Terminal; + } else { + self.clear_copy_mode_selection(); + self.mode = Mode::Navigate; + } + } + + pub(crate) fn settle_terminal_mode_after_focus(&mut self) { + self.mode = Mode::Terminal; + self.sync_copy_mode_with_focus(); + } + + pub(crate) fn clear_copy_mode_selection(&mut self) { + self.clear_selection(); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.selection = None; + } + } + + pub(crate) fn clear_copy_mode_for_removed_panes( + &mut self, + pane_ids: impl IntoIterator<Item = crate::layout::PaneId>, + ) { + let Some(copy_mode) = self.copy_mode else { + return; + }; + if pane_ids + .into_iter() + .any(|pane_id| pane_id == copy_mode.pane_id) + { + self.clear_selection(); + self.copy_mode = None; + if self.mode == Mode::Copy { + self.mode = if self.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + } + } + fn sync_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { let Some(copy_mode) = self.copy_mode else { return; @@ -718,6 +790,69 @@ mod tests { }) } + fn app_with_split_copy_runtime( + bytes: &[u8], + first_runtime: impl FnOnce(u16, u16, &[u8]) -> crate::terminal::TerminalRuntime, + ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); + let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 40, 5)); + let first_info = pane_infos + .iter() + .find(|info| info.id == first_pane) + .expect("first pane info"); + let second_info = pane_infos + .iter() + .find(|info| info.id == second_pane) + .expect("second pane info"); + ws.tabs[0].runtimes.insert( + first_pane, + first_runtime( + first_info.inner_rect.width, + first_info.inner_rect.height, + bytes, + ), + ); + ws.tabs[0].runtimes.insert( + second_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + second_info.inner_rect.width, + second_info.inner_rect.height, + b"", + ), + ); + ws.tabs[0].layout.focus_pane(first_pane); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + (app, first_pane, second_pane) + } + + fn app_with_split_copy_screen( + bytes: &[u8], + ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { + app_with_split_copy_runtime(bytes, |cols, rows, bytes| { + crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes) + }) + } + + fn app_with_split_copy_scrollback( + bytes: &[u8], + ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { + app_with_split_copy_runtime(bytes, |cols, rows, bytes| { + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + cols, + rows, + 16 * 1024, + bytes, + ) + }) + } + fn copy_mode_clipboard_text(app: &mut App) -> String { match app.event_rx.try_recv().expect("clipboard event") { AppEvent::ClipboardWrite { content } => { @@ -756,6 +891,14 @@ mod tests { .expect("copy mode scroll metrics") } + fn refresh_split_pane_infos(app: &mut App) { + app.state.view.pane_infos = app.state.workspaces[0] + .active_tab() + .expect("active tab") + .layout + .panes(Rect::new(0, 0, 40, 5)); + } + #[tokio::test] async fn enter_copy_mode_tracks_focused_pane() { let (mut app, pane_id) = app_with_copy_screen(b"alpha\nbeta\n"); @@ -768,6 +911,8 @@ mod tests { async fn copy_mode_ctrl_b_uses_page_up() { let bytes = numbered_lines_bytes(64); let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + app.state.prefix_code = KeyCode::Char('a'); + app.state.prefix_mods = KeyModifiers::CONTROL; app.state.enter_copy_mode(&app.terminal_runtimes); let height = app.state.copy_mode.expect("copy mode").cursor_row + 1; let expected_lines = copy_mode_page_lines(height, false); @@ -779,6 +924,156 @@ mod tests { } #[tokio::test] + async fn copy_mode_prefix_takes_priority_over_ctrl_b_page_up() { + let bytes = numbered_lines_bytes(64); + let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + app.state.enter_copy_mode(&app.terminal_runtimes); + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + + assert_eq!(app.state.mode, Mode::Prefix); + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); + assert!(app.state.copy_mode.is_some()); + } + + #[tokio::test] + async fn copy_mode_prefix_escape_returns_to_copy_mode() { + let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + let copy_mode = app.state.copy_mode.expect("copy mode"); + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())) + .await; + + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!(app.state.copy_mode, Some(copy_mode)); + } + + #[tokio::test] + async fn copy_mode_prefix_focus_keeps_copy_mode_on_source_pane() { + let (mut app, first_pane, second_pane) = app_with_split_copy_screen(b"alpha\nbeta\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + let copy_mode = app.state.copy_mode.expect("copy mode"); + assert_eq!(copy_mode.pane_id, first_pane); + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.mode, Mode::Terminal); + assert_eq!(app.state.copy_mode, Some(copy_mode)); + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + second_pane + ); + + refresh_split_pane_infos(&mut app); + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!(app.state.copy_mode, Some(copy_mode)); + assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane); + } + + #[tokio::test] + async fn copy_mode_focus_away_preserves_scrollback_position() { + let bytes = numbered_lines_bytes(64); + let (mut app, first_pane, second_pane) = app_with_split_copy_scrollback(&bytes); + app.state.enter_copy_mode(&app.terminal_runtimes); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + let scrolled_offset = copy_mode_offset_from_bottom(&app, first_pane); + assert!(scrolled_offset > 0); + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.mode, Mode::Terminal); + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + second_pane + ); + assert_eq!( + copy_mode_offset_from_bottom(&app, first_pane), + scrolled_offset + ); + } + + #[tokio::test] + async fn copy_mode_cancel_restores_scroll_after_workspace_switch() { + let bytes = numbered_lines_bytes(64); + let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + app.state + .workspaces + .push(crate::workspace::Workspace::test_new("other")); + app.state.enter_copy_mode(&app.terminal_runtimes); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); + + app.state.switch_workspace(1); + app.state.cancel_copy_mode(&app.terminal_runtimes); + + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); + assert!(app.state.copy_mode.is_none()); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn copy_mode_clears_when_source_tab_closes_after_focus_away() { + let (mut app, first_pane, _) = app_with_split_copy_screen(b"alpha\nbeta\n"); + let survivor_tab = app.state.workspaces[0].test_add_tab(Some("survivor")); + let survivor_pane = app.state.workspaces[0].tabs[survivor_tab].root_pane; + let survivor_terminal = app.state.workspaces[0].tabs[survivor_tab].panes[&survivor_pane] + .attached_terminal_id + .clone(); + app.state.terminals.insert( + survivor_terminal.clone(), + crate::terminal::TerminalState::new(survivor_terminal, "/tmp".into()), + ); + app.state.enter_copy_mode(&app.terminal_runtimes); + assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, first_pane); + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) + .await; + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_some()); + + assert!(!app.state.close_tab()); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + app.state.assert_invariants_for_test(); + } + + #[tokio::test] async fn copy_mode_ctrl_f_uses_page_down() { let bytes = numbered_lines_bytes(64); let (mut app, pane_id) = app_with_copy_scrollback(&bytes); @@ -1068,4 +1363,24 @@ mod tests { assert_eq!(app.state.mode, Mode::Terminal); assert!(app.state.copy_mode.is_none()); } + + #[tokio::test] + async fn copy_mode_same_tab_switch_preserves_selection() { + let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + if let Some(copy_mode) = app.state.copy_mode.as_mut() { + copy_mode.cursor_row = 0; + copy_mode.cursor_col = 0; + } + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); + + assert!(app.state.switch_workspace_tab(0, 0)); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); + assert_eq!(copy_mode_clipboard_text(&mut app), "alp"); + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 95afdbe9..495bcd53 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1444,7 +1444,7 @@ impl AppState { self.focus_pane_in_workspace(ws_idx, target.pane_id); self.toast = None; - self.mode = Mode::Terminal; + self.settle_terminal_mode_after_focus(); } pub(crate) fn scroll_pane_up( @@ -1736,11 +1736,13 @@ impl AppState { pane_id: crate::layout::PaneId, offset_from_bottom: usize, ) { - if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - { - rt.set_scroll_offset_from_bottom(offset_from_bottom); - } + for ws_idx in 0..self.workspaces.len() { + let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + else { + continue; + }; + rt.set_scroll_offset_from_bottom(offset_from_bottom); + return; } } diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index f4719ace..6accb22f 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -36,11 +36,20 @@ pub(crate) enum ActionContext { } impl App { + fn cancel_copy_mode_if_active(&mut self) { + if self.state.copy_mode.is_some() { + self.state.cancel_copy_mode(&self.terminal_runtimes); + } + } + pub(crate) fn handle_prefix_key(&mut self, raw_key: TerminalKey) { let key = raw_key.as_key_event(); self.state.update_dismissed = true; if self.state.is_prefix_key(raw_key) { + if self.state.copy_mode_pane_is_focused() { + self.state.cancel_copy_mode(&self.terminal_runtimes); + } if !self.pass_through_key_to_focused_pane(raw_key) { leave_command_mode(&mut self.state); } @@ -55,9 +64,19 @@ impl App { if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) { if action == NavigateAction::EditScrollback { let previous_mode = self.state.mode; + self.cancel_copy_mode_if_active(); self.launch_focused_scrollback_editor(); finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); + } else if action == NavigateAction::CopyMode { + self.cancel_copy_mode_if_active(); + self.execute_tui_navigate_action(action, ActionContext::Prefix); + } else if copy_mode_survives_prefix_action(action) { + self.execute_tui_navigate_action(action, ActionContext::Prefix); + if self.state.copy_mode.is_some() { + self.state.sync_copy_mode_with_focus(); + } } else { + self.cancel_copy_mode_if_active(); self.execute_tui_navigate_action(action, ActionContext::Prefix); } self.selection_autoscroll_deadline = None; @@ -65,6 +84,7 @@ impl App { } if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + self.cancel_copy_mode_if_active(); self.launch_custom_command(binding, ActionContext::Prefix); return; } @@ -1249,6 +1269,29 @@ pub(crate) enum NavigateAction { OpenNavigator, } +fn copy_mode_survives_prefix_action(action: NavigateAction) -> bool { + matches!( + action, + NavigateAction::SwitchWorkspace(_) + | NavigateAction::SwitchTab(_) + | NavigateAction::FocusAgent(_) + | NavigateAction::PreviousWorkspace + | NavigateAction::NextWorkspace + | NavigateAction::PreviousAgent + | NavigateAction::NextAgent + | NavigateAction::PreviousTab + | NavigateAction::NextTab + | NavigateAction::FocusPaneLeft + | NavigateAction::FocusPaneDown + | NavigateAction::FocusPaneUp + | NavigateAction::FocusPaneRight + | NavigateAction::CyclePaneNext + | NavigateAction::CyclePanePrevious + | NavigateAction::LastPane + | NavigateAction::OpenNotificationTarget + ) +} + fn indexed_navigation_action( state: &AppState, key: TerminalKey, @@ -1645,10 +1688,12 @@ fn finish_custom_command_context( } fn leave_command_mode(state: &mut AppState) { - state.mode = if state.active.is_some() { - Mode::Terminal + if state.copy_mode_pane_is_focused() { + state.mode = Mode::Copy; + } else if state.active.is_some() { + state.mode = Mode::Terminal; } else { - Mode::Navigate + state.mode = Mode::Navigate; }; } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index e62936c3..c071a010 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -824,8 +824,10 @@ mod tests { let mut app = AppState::test_new(); app.mode = Mode::Terminal; app.view.terminal_area = Rect::new(0, 0, 12, 3); + let ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; app.view.pane_infos = vec![PaneInfo { - id: PaneId::from_raw(1), + id: pane_id, rect: Rect::new(0, 0, 12, 3), inner_rect: Rect::default(), scrollbar_rect: None, @@ -833,10 +835,7 @@ mod tests { is_focused: false, }]; - let ws = Workspace::test_new("test"); - let terminal_id = ws.tabs[0].panes[&PaneId::from_raw(1)] - .attached_terminal_id - .clone(); + let terminal_id = ws.tabs[0].panes[&pane_id].attached_terminal_id.clone(); let mut terminal_state = TerminalState::new(terminal_id.clone(), "/tmp".into()); terminal_state.set_manual_label("1 模块组织(已定)".into()); app.terminals.insert(terminal_id, terminal_state); |
