From 3ef6e8cfb82f4497832c49aa3a9b48a9a9ac3c74 Mon Sep 17 00:00:00 2001 From: Realm Date: Thu, 27 Aug 2026 00:09:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(terminal):=20=E4=BF=AE=E5=A4=8D=20SSH=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=91=BD=E4=BB=A4=E6=AE=8B=E7=95=99=E4=B8=8E?= =?UTF-8?q?=E5=85=89=E6=A0=87=E5=AE=9A=E4=BD=8D=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 3 +- src/app/constants.rs | 7 - src/app/dialogs.rs | 2 - src/app/mod.rs | 295 ++++++++------ src/app/search.rs | 142 +++---- src/app/ui.rs | 20 +- src/backend/local.rs | 402 +++++++++++++++++-- src/desktop_notification.rs | 22 + src/session/config.rs | 1 - src/session/mod.rs | 92 ++--- src/sftp/mod.rs | 53 +-- src/system/mod.rs | 55 +-- src/terminal/input.rs | 778 +++++++++++++++++++++++++++++++++++- src/terminal/mod.rs | 565 +++++++++++++++++++++++--- 14 files changed, 1956 insertions(+), 481 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 89f7f7e..50b7d4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ windows = { version = "0.57", features = [ "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", + "UI_Notifications", ] } winreg = "0.52" @@ -80,7 +81,7 @@ desktop-notify = { package = "notify-rust", version = "=4.11.5", default-feature mac-notification-sys = "=0.6.15" objc2 = "0.6.4" objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSDockTile"] } -objc2-foundation = { version = "0.3.2", features = ["NSString"] } +objc2-foundation = { version = "0.3.2", features = ["NSString", "NSUserNotification"] } [package.metadata.deb] maintainer = "ashell contributors" diff --git a/src/app/constants.rs b/src/app/constants.rs index 625c95f..6efb55d 100644 --- a/src/app/constants.rs +++ b/src/app/constants.rs @@ -3,11 +3,4 @@ pub(crate) const DEFAULT_ROWS: u16 = 30; pub(crate) const SIDEBAR_WIDTH: f32 = 310.0; pub(crate) const TERMINAL_SCROLLBAR_GUTTER: f32 = 16.0; -#[allow(dead_code)] -pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0; -#[allow(dead_code)] -pub(crate) const TERMINAL_PADDING_X: f32 = 32.0; -#[allow(dead_code)] -pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0; - pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal"; diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 4a3daf6..25c1c87 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -645,7 +645,6 @@ impl Ashell { let selector_focus_handle = self.selector_focus_handle.clone(); let deferred_selector_focus_handle = selector_focus_handle.clone(); let sessions = self.config.sessions().to_vec(); - let active_session_id = self.active_session_id().map(ToOwned::to_owned); self.selector_selection = self.default_selector_index(); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog @@ -672,7 +671,6 @@ impl Ashell { .content({ let view = view.clone(); let sessions = sessions.clone(); - let _active_session_id = active_session_id.clone(); let selector_focus_handle = selector_focus_handle.clone(); move |content, window, _cx| { let selected_index = view.read(_cx).selector_selection; diff --git a/src/app/mod.rs b/src/app/mod.rs index fb0d587..c55ec26 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -44,6 +44,8 @@ use crate::{ text_encoding::TextEncoding, }; +const SYSTEM_HISTORY_LIMIT: usize = 20; + #[derive(Clone, Debug)] pub(crate) enum PaneLayout { Single(String), @@ -62,10 +64,27 @@ pub(crate) struct TabGroup { impl PaneLayout { pub fn tab_ids(&self) -> Vec<&str> { + let mut tab_ids = Vec::new(); + self.collect_tab_ids(&mut tab_ids); + tab_ids + } + + fn collect_tab_ids<'a>(&'a self, tab_ids: &mut Vec<&'a str>) { match self { - PaneLayout::Single(id) => vec![id.as_str()], + PaneLayout::Single(id) => tab_ids.push(id), PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => { - children.iter().flat_map(|c| c.tab_ids()).collect() + for child in children { + child.collect_tab_ids(tab_ids); + } + } + } + } + + pub fn first_tab_id(&self) -> Option<&str> { + match self { + PaneLayout::Single(id) => Some(id), + PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => { + children.iter().find_map(PaneLayout::first_tab_id) } } } @@ -105,13 +124,12 @@ impl PaneLayout { } } - pub fn remove_tab(&mut self, tab_id: &str) -> bool { + pub fn remove_tab(&mut self, tab_id: &str) { match self { PaneLayout::Single(id) if id == tab_id => { *self = PaneLayout::Single(String::new()); - true } - PaneLayout::Single(_) => false, + PaneLayout::Single(_) => {} PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => { for child in children.iter_mut() { child.remove_tab(tab_id); @@ -124,12 +142,10 @@ impl PaneLayout { *self = replacement; } } - true } } } - #[allow(dead_code)] pub fn total_panes(&self) -> usize { match self { PaneLayout::Single(_) => 1, @@ -156,6 +172,15 @@ fn should_show_terminal_notification( } } +fn retain_unread_terminal_notifications( + unread: &mut HashSet, + mut retain: impl FnMut(&str) -> bool, +) -> bool { + let previous_count = unread.len(); + unread.retain(|tab_id| retain(tab_id)); + previous_count != unread.len() +} + pub(crate) struct TerminalScrollbarState { line_height: Pixels, total_lines: usize, @@ -343,6 +368,7 @@ pub(crate) struct Ashell { pub(crate) show_command_history: bool, pub(crate) ssh_command_buffers: HashMap, pub(crate) ssh_command_starts: HashMap, + pub(crate) ssh_command_input_uncertain: HashSet, pub(crate) system_status: Option, pub(crate) server_monitor_view: ServerMonitorView, pub(crate) remote_processes: Vec, @@ -358,7 +384,7 @@ pub(crate) struct Ashell { pub(crate) terminal_panel_bounds: Option>, pub(crate) terminal_bounds: HashMap>, pub(crate) terminal_selecting: bool, - pub(crate) dragging_splitter: Option<(Vec, usize)>, // (parent_path, child_index) + pub(crate) dragging_splitter: Option>, pub(crate) drag_split_origin: Option>, pub(crate) terminal_marked_text: Option, pub(crate) sftp_panel_minimized: bool, @@ -871,6 +897,7 @@ impl Ashell { show_command_history: false, ssh_command_buffers: HashMap::new(), ssh_command_starts: HashMap::new(), + ssh_command_input_uncertain: HashSet::new(), system_status: None, server_monitor_view: ServerMonitorView::default(), remote_processes: Vec::new(), @@ -902,9 +929,9 @@ impl Ashell { keybind_error: None, keybinds_suspended: false, system, - cpu_history: Vec::with_capacity(20), - net_rx_history: Vec::with_capacity(20), - net_tx_history: Vec::with_capacity(20), + cpu_history: Vec::with_capacity(SYSTEM_HISTORY_LIMIT), + net_rx_history: Vec::with_capacity(SYSTEM_HISTORY_LIMIT), + net_tx_history: Vec::with_capacity(SYSTEM_HISTORY_LIMIT), last_system_sample: Instant::now(), last_theme_sync: Instant::now(), @@ -1204,19 +1231,17 @@ impl Ashell { .pane_root .tab_ids() .into_iter() - .map(ToOwned::to_owned) .collect::>() }) .or_else(|| { self.active_tab - .as_ref() - .map(|tab_id| HashSet::from([tab_id.clone()])) + .as_deref() + .map(|tab_id| HashSet::from([tab_id])) }) .unwrap_or_default(); - let previous_count = self.unread_terminal_notifications.len(); - self.unread_terminal_notifications - .retain(|tab_id| !visible_tab_ids.contains(tab_id)); - if previous_count != self.unread_terminal_notifications.len() { + if retain_unread_terminal_notifications(&mut self.unread_terminal_notifications, |tab_id| { + !visible_tab_ids.contains(tab_id) + }) { self.update_unread_indicator(); } } @@ -1225,12 +1250,11 @@ impl Ashell { let open_tab_ids = self .tabs .iter() - .map(|tab| tab.id.clone()) + .map(|tab| tab.id.as_str()) .collect::>(); - let previous_count = self.unread_terminal_notifications.len(); - self.unread_terminal_notifications - .retain(|tab_id| open_tab_ids.contains(tab_id)); - if previous_count != self.unread_terminal_notifications.len() { + if retain_unread_terminal_notifications(&mut self.unread_terminal_notifications, |tab_id| { + open_tab_ids.contains(tab_id) + }) { self.update_unread_indicator(); } } @@ -1269,8 +1293,12 @@ impl Ashell { return false; } + let became_active = window_active && !self.window_active; self.window_active = window_active; self.report_active_terminal_focus(window_active); + if became_active { + crate::desktop_notification::clear_current_app_delivered_notifications(); + } self.clear_visible_terminal_notifications(); true } @@ -1350,7 +1378,6 @@ impl Ashell { if let Some(progress) = self.connection_progress.as_mut() { if progress.tab_id == tab_id { progress.lines.push(text.clone().into()); - let _idx = progress.lines.len().saturating_sub(1); self.connection_scroll_handle .set_offset(gpui::point(px(0.), px(-99999.0))); } @@ -1461,19 +1488,7 @@ impl Ashell { if self.is_connected_system_tab(&tab_id) { self.remote_sample_in_flight = false; self.system_status = None; - self.system = snapshot.clone(); - self.cpu_history.push(snapshot.cpu_percent); - if self.cpu_history.len() > 20 { - self.cpu_history.remove(0); - } - self.net_rx_history.push(snapshot.net_rx_rate as f32); - if self.net_rx_history.len() > 20 { - self.net_rx_history.remove(0); - } - self.net_tx_history.push(snapshot.net_tx_rate as f32); - if self.net_tx_history.len() > 20 { - self.net_tx_history.remove(0); - } + self.apply_system_snapshot(snapshot); } } BackendEvent::RemoteSystemUnavailable { tab_id, reason } => { @@ -1556,16 +1571,7 @@ impl Ashell { tab.disconnected_reason = Some(reason.clone()); } if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { - self.system = SystemSnapshot::default(); - self.cpu_history.clear(); - self.net_rx_history.clear(); - self.net_tx_history.clear(); - self.remote_sample_in_flight = false; - self.remote_processes_in_flight = false; - self.remote_processes.clear(); - self.remote_ports_in_flight = false; - self.remote_ports.clear(); - self.terminating_processes.clear(); + self.reset_system_monitor_state(); self.system_status = Some(reason.clone().into()); self.remote_process_status = Some(reason.clone().into()); self.remote_ports_status = Some(reason.clone().into()); @@ -1573,7 +1579,6 @@ impl Ashell { if let Some(progress) = self.connection_progress.as_mut() { if progress.tab_id == tab_id { progress.lines.push(reason.clone().into()); - let _idx = progress.lines.len().saturating_sub(1); self.connection_scroll_handle .set_offset(gpui::point(px(0.), px(-99999.0))); progress.title = t!("connection_failed").into(); @@ -1583,7 +1588,6 @@ impl Ashell { self.status = reason.into(); } BackendEvent::TransferProgress { - tab_id: _, id, transferred, total, @@ -1712,45 +1716,62 @@ impl Ashell { pub(crate) fn sample_system_if_due(&mut self) -> bool { if self.last_system_sample.elapsed() >= SystemSampler::interval() { self.last_system_sample = Instant::now(); - if let Some(ref tab_id) = self.system_tab_id.clone() { - let ssh_connected = self - .tabs + let ssh_connected = self.system_tab_id.as_deref().and_then(|tab_id| { + self.tabs .iter() - .find(|tab| tab.id == *tab_id && tab.kind == TabKind::Ssh) - .map(|tab| tab.connected); - if let Some(connected) = ssh_connected { - if connected { - self.request_active_system_snapshot(); - if self.active_dialog == Some(DialogKind::Processes) { - self.request_active_process_snapshot(); - } - if self.active_dialog == Some(DialogKind::Ports) { - self.request_active_port_snapshot(); - } + .find(|tab| tab.id == tab_id && tab.kind == TabKind::Ssh) + .map(|tab| tab.connected) + }); + if let Some(connected) = ssh_connected { + if connected { + self.request_active_system_snapshot(); + if self.active_dialog == Some(DialogKind::Processes) { + self.request_active_process_snapshot(); + } + if self.active_dialog == Some(DialogKind::Ports) { + self.request_active_port_snapshot(); } - return false; } + return false; } let snapshot = self.system_sampler.sample(); - let cpu_usage = snapshot.cpu_percent; - self.cpu_history.push(cpu_usage); - if self.cpu_history.len() > 20 { - self.cpu_history.remove(0); - } - self.net_rx_history.push(snapshot.net_rx_rate as f32); - if self.net_rx_history.len() > 20 { - self.net_rx_history.remove(0); - } - self.net_tx_history.push(snapshot.net_tx_rate as f32); - if self.net_tx_history.len() > 20 { - self.net_tx_history.remove(0); - } - self.system = snapshot; + self.apply_system_snapshot(snapshot); return true; } false } + fn apply_system_snapshot(&mut self, snapshot: SystemSnapshot) { + Self::push_system_history_sample(&mut self.cpu_history, snapshot.cpu_percent); + Self::push_system_history_sample(&mut self.net_rx_history, snapshot.net_rx_rate as f32); + Self::push_system_history_sample(&mut self.net_tx_history, snapshot.net_tx_rate as f32); + self.system = snapshot; + } + + fn push_system_history_sample(history: &mut Vec, sample: f32) { + history.push(sample); + if history.len() > SYSTEM_HISTORY_LIMIT { + history.remove(0); + } + } + + pub(crate) fn reset_system_monitor_state(&mut self) { + self.system = SystemSnapshot::default(); + self.cpu_history.clear(); + self.net_rx_history.clear(); + self.net_tx_history.clear(); + self.remote_sample_in_flight = false; + self.remote_processes_in_flight = false; + self.remote_processes.clear(); + self.remote_ports_in_flight = false; + self.remote_ports.clear(); + self.terminating_processes.clear(); + self.system_status = None; + self.remote_process_status = None; + self.remote_ports_status = None; + self.expanded_process_pid = None; + } + pub(crate) fn sync_theme_if_due(&mut self, cx: &mut Context) { if self.follow_system_theme && self.last_theme_sync.elapsed() >= Duration::from_secs(1) { self.last_theme_sync = Instant::now(); @@ -1760,51 +1781,48 @@ impl Ashell { } pub(crate) fn request_active_system_snapshot(&mut self) { - let Some(ref tab_id) = self.system_tab_id.clone() else { - return; - }; - let Some(backend) = (|| { - let tab = self.tabs.iter().find(|t| t.id == *tab_id)?; - if !tab.connected { - return None; - } - Some(tab.backend.clone()) - })() else { - return; - }; if self.remote_sample_in_flight { return; } + let Some(backend) = self + .active_connected_system_tab() + .map(|tab| tab.backend.clone()) + else { + return; + }; if let Ok(backend) = backend.lock() { self.remote_sample_in_flight = true; backend.send(crate::terminal::BackendCommand::SampleMetrics); } } + fn connected_system_tab(&self, tab_id: &str) -> Option<&TerminalTab> { + if self.system_tab_id.as_deref() != Some(tab_id) { + return None; + } + self.tabs + .iter() + .find(|tab| tab.id == tab_id && tab.kind == TabKind::Ssh && tab.connected) + } + + fn active_connected_system_tab(&self) -> Option<&TerminalTab> { + self.connected_system_tab(self.system_tab_id.as_deref()?) + } + fn is_connected_system_tab(&self, tab_id: &str) -> bool { - self.system_tab_id.as_deref() == Some(tab_id) - && self - .tabs - .iter() - .any(|tab| tab.id == tab_id && tab.kind == TabKind::Ssh && tab.connected) + self.connected_system_tab(tab_id).is_some() } pub(crate) fn request_active_process_snapshot(&mut self) { - let Some(ref tab_id) = self.system_tab_id.clone() else { - return; - }; - let Some(backend) = (|| { - let tab = self - .tabs - .iter() - .find(|tab| tab.id == *tab_id && tab.kind == TabKind::Ssh && tab.connected)?; - Some(tab.backend.clone()) - })() else { - return; - }; if self.remote_processes_in_flight { return; } + let Some(backend) = self + .active_connected_system_tab() + .map(|tab| tab.backend.clone()) + else { + return; + }; if let Ok(backend) = backend.lock() { self.remote_processes_in_flight = true; if self.remote_processes.is_empty() { @@ -1815,21 +1833,15 @@ impl Ashell { } pub(crate) fn request_active_port_snapshot(&mut self) { - let Some(ref tab_id) = self.system_tab_id.clone() else { - return; - }; - let Some(backend) = (|| { - let tab = self - .tabs - .iter() - .find(|tab| tab.id == *tab_id && tab.kind == TabKind::Ssh && tab.connected)?; - Some(tab.backend.clone()) - })() else { - return; - }; if self.remote_ports_in_flight { return; } + let Some(backend) = self + .active_connected_system_tab() + .map(|tab| tab.backend.clone()) + else { + return; + }; if let Ok(backend) = backend.lock() { self.remote_ports_in_flight = true; if self.remote_ports.is_empty() { @@ -1878,16 +1890,10 @@ impl Ashell { if pid <= 1 || self.terminating_processes.contains(&pid) { return; } - if self.system_tab_id.as_deref() != Some(tab_id.as_str()) { - return; - } - let Some(backend) = (|| { - let tab = self - .tabs - .iter() - .find(|tab| tab.id == tab_id && tab.kind == TabKind::Ssh && tab.connected)?; - Some(tab.backend.clone()) - })() else { + let Some(backend) = self + .connected_system_tab(&tab_id) + .map(|tab| tab.backend.clone()) + else { return; }; if let Ok(backend) = backend.lock() { @@ -2277,8 +2283,35 @@ impl Ashell { } #[cfg(test)] -mod terminal_notification_tests { - use super::{TerminalNotificationOccasion, should_show_terminal_notification}; +mod tests { + use super::{PaneLayout, TerminalNotificationOccasion, should_show_terminal_notification}; + + #[test] + fn pane_layout_queries_and_removes_tabs_in_display_order() { + let mut layout = PaneLayout::Horizontal( + vec![ + PaneLayout::Single("first".to_string()), + PaneLayout::Vertical( + vec![ + PaneLayout::Single("second".to_string()), + PaneLayout::Single("third".to_string()), + ], + 0.5, + ), + ], + 0.5, + ); + + assert_eq!(layout.tab_ids(), vec!["first", "second", "third"]); + assert_eq!(layout.first_tab_id(), Some("first")); + assert_eq!(layout.total_panes(), 3); + + layout.remove_tab("first"); + + assert_eq!(layout.tab_ids(), vec!["second", "third"]); + assert_eq!(layout.first_tab_id(), Some("second")); + assert_eq!(layout.total_panes(), 2); + } #[test] fn applies_terminal_notification_occasion_rules() { diff --git a/src/app/search.rs b/src/app/search.rs index 9bf10e3..829645b 100644 --- a/src/app/search.rs +++ b/src/app/search.rs @@ -61,6 +61,20 @@ impl Ashell { }); } + fn search_tab_index(&self) -> Option { + self.active_tab + .as_deref() + .and_then(|id| self.tabs.iter().position(|tab| tab.id == id)) + .or_else(|| { + self.active_group + .as_ref() + .and_then(|group_id| self.tab_groups.iter().find(|group| &group.id == group_id)) + .and_then(|group| group.pane_root.first_tab_id()) + .and_then(|id| self.tabs.iter().position(|tab| tab.id == id)) + }) + .or_else(|| (!self.tabs.is_empty()).then_some(0)) + } + pub(crate) fn perform_search(&mut self, window: &mut Window, cx: &mut Context) { let query = self.search_input.read(cx).text().to_string(); if query.is_empty() { @@ -72,28 +86,7 @@ impl Ashell { return; } - // Find the active tab — try active_tab first, then fall back to the - // first tab in the active group, then any tab. - let tab = self - .active_tab - .as_ref() - .and_then(|id| self.tabs.iter().find(|t| &t.id == id)); - - let tab = tab.or_else(|| { - let first_id = self - .active_group - .as_ref() - .and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid)) - .and_then(|g| g.pane_root.tab_ids().into_iter().next()) - .map(|s| s.to_string()); - first_id - .as_deref() - .and_then(|id| self.tabs.iter().find(|t| t.id == id)) - }); - - let tab = tab.or_else(|| self.tabs.first()); - - let Some(tab) = tab else { + let Some(tab_index) = self.search_tab_index() else { self.status = t!("no_results").into(); self.refocus_search_input(window, cx); cx.notify(); @@ -101,7 +94,8 @@ impl Ashell { }; // Remember which tab was searched so highlights only appear in that pane. - self.search_target_tab = Some(tab.id.clone()); + self.search_target_tab = Some(self.tabs[tab_index].id.clone()); + let tab = &self.tabs[tab_index]; let query_lower = query.to_lowercase(); let query_byte_len = query.len(); @@ -145,6 +139,7 @@ impl Ashell { } } + matches.sort_unstable(); let match_count = count_match_groups(&matches); self.search_query = query; @@ -205,7 +200,7 @@ impl Ashell { self.active_group .as_ref() .and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid)) - .and_then(|g| g.pane_root.tab_ids().into_iter().next()) + .and_then(|g| g.pane_root.first_tab_id()) .map(|s| s.to_string()) }); @@ -253,35 +248,14 @@ impl Ashell { return None; } - // Get current display_offset to convert grid line → viewport row. - let tab = self - .active_tab - .as_ref() - .and_then(|id| self.tabs.iter().find(|t| &t.id == id)); - - let tab = tab.or_else(|| { - let first_id = self - .active_group - .as_ref() - .and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid)) - .and_then(|g| g.pane_root.tab_ids().into_iter().next()) - .map(|s| s.to_string()); - first_id - .as_deref() - .and_then(|id| self.tabs.iter().find(|t| t.id == id)) - }); - - let tab = tab.or_else(|| self.tabs.first()); - - let tab = tab?; + let tab = self.tabs.get(self.search_tab_index()?)?; let snapshot = tab.render_snapshot(false); let display_offset = snapshot.display_offset as i32; let rows = snapshot.rows as i32; let mut map = HashMap::new(); - let mut sorted: Vec<(i32, i32)> = self.search_matches.clone(); - sorted.sort(); + let sorted = &self.search_matches; let mut group_idx = 0; let mut i = 0; @@ -296,27 +270,15 @@ impl Ashell { // grid_line → viewport row: vp_row = grid_line + display_offset let (grid_line, _) = sorted[i]; let vp_row = grid_line + display_offset; - let mut j = i; + let next_i = next_match_group_index(sorted, i); if vp_row >= 0 && vp_row < rows { - while j < sorted.len() && sorted[j].0 == grid_line { - if j > i && sorted[j].1 != sorted[j - 1].1 + 1 { - break; - } - map.insert((vp_row, sorted[j].1), color); - j += 1; - } - } else { - // Outside current viewport — skip. - while j < sorted.len() && sorted[j].0 == grid_line { - if j > i && sorted[j].1 != sorted[j - 1].1 + 1 { - break; - } - j += 1; + for &(_, col) in &sorted[i..next_i] { + map.insert((vp_row, col), color); } } group_idx += 1; - i = j; + i = next_i; } Some(map) @@ -442,44 +404,50 @@ impl Ashell { /// Count distinct match groups in a sorted list of (row, col) positions. /// A group is a run of consecutive columns in the same row. fn count_match_groups(matches: &[(i32, i32)]) -> usize { - if matches.is_empty() { - return 0; - } - let mut sorted: Vec<(i32, i32)> = matches.to_vec(); - sorted.sort(); let mut count = 0; let mut i = 0; - while i < sorted.len() { + while i < matches.len() { count += 1; - let (r, _) = sorted[i]; - i += 1; - // Skip consecutive columns in the same row. - while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 { - i += 1; - } + i = next_match_group_index(matches, i); } count } /// Find the (row, col) start of the Nth distinct match group. fn find_nth_match_start(matches: &[(i32, i32)], n: usize) -> Option<(i32, i32)> { - if matches.is_empty() { - return None; - } - let mut sorted: Vec<(i32, i32)> = matches.to_vec(); - sorted.sort(); let mut group_idx = 0; let mut i = 0; - while i < sorted.len() { + while i < matches.len() { if group_idx == n { - return Some(sorted[i]); + return Some(matches[i]); } group_idx += 1; - let (r, _) = sorted[i]; - i += 1; - while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 { - i += 1; - } + i = next_match_group_index(matches, i); } None } + +fn next_match_group_index(matches: &[(i32, i32)], start: usize) -> usize { + let row = matches[start].0; + let mut end = start + 1; + while end < matches.len() && matches[end].0 == row && matches[end].1 == matches[end - 1].1 + 1 { + end += 1; + } + end +} + +#[cfg(test)] +mod tests { + use super::{count_match_groups, find_nth_match_start}; + + #[test] + fn groups_consecutive_search_matches_by_row() { + let matches = [(0, 1), (0, 2), (0, 5), (1, 0), (1, 1)]; + + assert_eq!(count_match_groups(&matches), 3); + assert_eq!(find_nth_match_start(&matches, 0), Some((0, 1))); + assert_eq!(find_nth_match_start(&matches, 1), Some((0, 5))); + assert_eq!(find_nth_match_start(&matches, 2), Some((1, 0))); + assert_eq!(find_nth_match_start(&matches, 3), None); + } +} diff --git a/src/app/ui.rs b/src/app/ui.rs index dee301e..ad9089f 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -3851,7 +3851,7 @@ impl Ashell { pointer_button("new-connection-group") .ghost() .icon(IconName::Plus) - .label(t!("new_connection_short").to_string()) + .label(t!("connection_group").to_string()) .tooltip(t!("new_connection_group").to_string()) .on_click(cx.listener(|this, _, window, cx| { this.show_connection_group_dialog(None, window, cx); @@ -5157,7 +5157,7 @@ impl Ashell { } }) .unwrap_or(cx.theme().success); - let has_multiple_panes = this.pane_root.tab_ids().len() > 1; + let has_multiple_panes = this.pane_root.total_panes() > 1; if !is_focused { el = el.opacity(0.85); @@ -5235,13 +5235,7 @@ impl Ashell { cx.listener(move |this, event, window, cx| { window.prevent_default(); cx.stop_propagation(); - this.start_drag_split( - splitter_path.clone(), - i, - event, - window, - cx, - ); + this.start_drag_split(splitter_path.clone(), event); }), ) .into_any_element(), @@ -5285,13 +5279,7 @@ impl Ashell { cx.listener(move |this, event, window, cx| { window.prevent_default(); cx.stop_propagation(); - this.start_drag_split( - splitter_path.clone(), - i, - event, - window, - cx, - ); + this.start_drag_split(splitter_path.clone(), event); }), ) .into_any_element(), diff --git a/src/backend/local.rs b/src/backend/local.rs index e742c63..3a022ec 100644 --- a/src/backend/local.rs +++ b/src/backend/local.rs @@ -1,7 +1,8 @@ use std::{ + fs, io::{Read, Write}, path::{Path, PathBuf}, - sync::mpsc, + sync::{OnceLock, mpsc}, thread, time::Duration, }; @@ -20,6 +21,281 @@ use crate::terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEve #[cfg(not(windows))] const DIRECTORY_POLL_INTERVAL: Duration = Duration::from_secs(1); +const BASH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative. +if [[ ${ASHELL_BASH_LOGIN:-0} == 1 ]]; then + [[ -r /etc/profile ]] && source /etc/profile + if [[ -r $HOME/.bash_profile ]]; then + source "$HOME/.bash_profile" + elif [[ -r $HOME/.bash_login ]]; then + source "$HOME/.bash_login" + elif [[ -r $HOME/.profile ]]; then + source "$HOME/.profile" + fi +elif [[ -r $HOME/.bashrc ]]; then + source "$HOME/.bashrc" +fi + +if [[ -z ${__ASHELL_BASH_INTEGRATION_ACTIVE:-} ]]; then + __ASHELL_BASH_INTEGRATION_ACTIVE=1 + __ashell_prompt_suffix='\[\e]133;B\a\]' + __ashell_secondary_prefix='\[\e]133;A;k=s\a\]' + + __ashell_install_prompt_markers() { + PS1=${PS1//"$__ashell_prompt_suffix"/} + PS1+=$__ashell_prompt_suffix + PS2=${PS2//"$__ashell_secondary_prefix"/} + PS2=${PS2//"$__ashell_prompt_suffix"/} + PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}" + } + + __ashell_precmd() { + local __ashell_last_status=$? + local __ashell_cwd=$PWD + __ashell_install_prompt_markers + if [[ ${ASHELL_WINDOWS_GIT_BASH:-0} == 1 ]]; then + __ashell_cwd=$(pwd -W 2>/dev/null) || __ashell_cwd=$PWD + fi + printf '\033]133;D;%s\007\033]0;ASHELL_CWD:%s\007\033]133;A\007' \ + "$__ashell_last_status" "$__ashell_cwd" + return "$__ashell_last_status" + } + + case $(declare -p PROMPT_COMMAND 2>/dev/null) in + 'declare -a'*) + PROMPT_COMMAND+=(__ashell_precmd) + ;; + *) + [[ -n ${PROMPT_COMMAND:-} ]] && PROMPT_COMMAND+=$'\n' + PROMPT_COMMAND+='__ashell_precmd' + ;; + esac + __ashell_install_prompt_markers +fi +"#; + +const ZSH_ENV_WRAPPER: &str = r#"# Generated by ashell. Redirect startup back to the user's files first. +typeset -g ASHELL_USER_ZDOTDIR="${ASHELL_ORIGINAL_ZDOTDIR:-$HOME}" +if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshenv" ]]; then + export ZDOTDIR="$ASHELL_USER_ZDOTDIR" + source "$ASHELL_USER_ZDOTDIR/.zshenv" + typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}" +fi +export ASHELL_USER_ZDOTDIR +export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR" +"#; + +const ZSH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative. +if [[ ${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/* ]]; then + HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}" +fi + +if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshrc" ]]; then + export ZDOTDIR="$ASHELL_USER_ZDOTDIR" + source "$ASHELL_USER_ZDOTDIR/.zshrc" + typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}" + export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR" +fi + +if [[ -z ${__ASHELL_ZSH_INTEGRATION_ACTIVE:-} ]]; then + typeset -g __ASHELL_ZSH_INTEGRATION_ACTIVE=1 + typeset -g __ashell_prompt_suffix=$'%{\e]133;B\a%}' + typeset -g __ashell_secondary_prefix=$'%{\e]133;A;k=s\a%}' + + __ashell_install_prompt_markers() { + PROMPT=${PROMPT//$__ashell_prompt_suffix/} + PROMPT+=$__ashell_prompt_suffix + PS2=${PS2//$__ashell_secondary_prefix/} + PS2=${PS2//$__ashell_prompt_suffix/} + PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}" + } + + __ashell_precmd() { + local __ashell_last_status=$? + __ashell_install_prompt_markers + print -rn -- $'\e]133;D;'"$__ashell_last_status"$'\a' + print -rn -- $'\e]0;ASHELL_CWD:'"$PWD"$'\a' + print -rn -- $'\e]133;A\a' + return "$__ashell_last_status" + } + + __ashell_preexec() { + print -rn -- $'\e]133;C\a' + } + + autoload -Uz add-zsh-hook + add-zsh-hook -d precmd __ashell_precmd 2>/dev/null + add-zsh-hook -d preexec __ashell_preexec 2>/dev/null + add-zsh-hook precmd __ashell_precmd + add-zsh-hook preexec __ashell_preexec + __ashell_install_prompt_markers +fi +export ZDOTDIR="$ASHELL_USER_ZDOTDIR" +"#; + +const FISH_INTEGRATION_COMMAND: &str = r#" +if status --is-interactive; and not functions -q __ashell_original_fish_prompt + functions -c fish_prompt __ashell_original_fish_prompt + + function __ashell_common_prompt + set -l __ashell_last_status $status + printf '\033]133;D;%s\007' $__ashell_last_status + printf '\033]0;ASHELL_CWD:%s\007' $PWD + printf '\033]133;A\007' + return $__ashell_last_status + end + + function __ashell_preexec --on-event fish_preexec + printf '\033]133;C\007' + end + + function fish_prompt + __ashell_common_prompt + __ashell_original_fish_prompt + printf '\033]133;B\007' + end +end +"#; + +#[cfg(any(windows, test))] +const POWERSHELL_SHELL_INTEGRATION: &str = r#"& { + $global:AshellOriginalPrompt = $function:prompt + function global:prompt { + $promptText = if ($global:AshellOriginalPrompt) { & $global:AshellOriginalPrompt } else { "PS $PWD> " } + $cwd = $PWD.ProviderPath + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cwd)) + [Console]::Write("$([char]27)]133;D$([char]7)$([char]27)]133;A$([char]7)") + [Console]::Write("$([char]27)]0;ASHELL_CWD_B64:$encoded$([char]7)") + "$promptText$([char]27)]133;B$([char]7)" + } + + try { + $options = Get-PSReadLineOption -ErrorAction Stop + $continuation = "$([char]27)]133;A;k=s$([char]7)$($options.ContinuationPrompt)$([char]27)]133;B$([char]7)" + Set-PSReadLineOption -ContinuationPrompt $continuation -ErrorAction Stop + } catch {} +}"#; + +#[derive(Debug)] +struct ShellIntegrationPaths { + bash_rc: PathBuf, + zsh_dir: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileShellIntegration { + Bash { login: bool }, + Zsh, + Fish, +} + +static SHELL_INTEGRATION_PATHS: OnceLock> = OnceLock::new(); + +fn file_shell_integration(executable: &Path, login_bash: bool) -> Option { + let executable_name = executable + .file_stem()? + .to_string_lossy() + .to_ascii_lowercase(); + match executable_name.as_str() { + "bash" => Some(FileShellIntegration::Bash { login: login_bash }), + "zsh" => Some(FileShellIntegration::Zsh), + "fish" => Some(FileShellIntegration::Fish), + _ => None, + } +} + +fn shell_integration_paths() -> Result<&'static ShellIntegrationPaths> { + SHELL_INTEGRATION_PATHS + .get_or_init(|| create_shell_integration_paths().map_err(|error| format!("{error:#}"))) + .as_ref() + .map_err(|message| anyhow::anyhow!("{message}")) +} + +fn create_shell_integration_paths() -> Result { + let root = std::env::temp_dir().join(format!( + "ashell-shell-integration-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let zsh_dir = root.join("zsh"); + + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&zsh_dir) + .with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?; + } + #[cfg(not(unix))] + fs::create_dir_all(&zsh_dir) + .with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?; + + let bash_rc = root.join("bashrc"); + write_shell_integration_file(&bash_rc, BASH_INTEGRATION_RC)?; + write_shell_integration_file(&zsh_dir.join(".zshenv"), ZSH_ENV_WRAPPER)?; + write_shell_integration_file(&zsh_dir.join(".zshrc"), ZSH_INTEGRATION_RC)?; + + Ok(ShellIntegrationPaths { bash_rc, zsh_dir }) +} + +fn write_shell_integration_file(path: &Path, contents: &str) -> Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600); + } + + let mut file = options + .open(path) + .with_context(|| format!("create shell integration file {}", path.display()))?; + file.write_all(contents.as_bytes()) + .with_context(|| format!("write shell integration file {}", path.display())) +} + +fn configure_file_shell_integration( + cmd: &mut CommandBuilder, + executable: &Path, + login_bash: bool, +) -> Result { + let Some(integration) = file_shell_integration(executable, login_bash) else { + return Ok(false); + }; + + match integration { + FileShellIntegration::Bash { login } => { + let paths = shell_integration_paths()?; + cmd.env("ASHELL_BASH_LOGIN", if login { "1" } else { "0" }); + cmd.env("ASHELL_WINDOWS_GIT_BASH", if login { "1" } else { "0" }); + cmd.arg("--rcfile"); + cmd.arg(paths.bash_rc.as_os_str()); + cmd.arg("-i"); + } + FileShellIntegration::Zsh => { + let paths = shell_integration_paths()?; + if let Some(original_zdotdir) = std::env::var_os("ZDOTDIR") { + cmd.env("ASHELL_ORIGINAL_ZDOTDIR", original_zdotdir); + } + cmd.env("ASHELL_INTEGRATION_ZDOTDIR", paths.zsh_dir.as_os_str()); + cmd.env("ZDOTDIR", paths.zsh_dir.as_os_str()); + } + FileShellIntegration::Fish => { + cmd.args(["-C", FISH_INTEGRATION_COMMAND]); + } + } + cmd.env("ASHELL_SHELL_INTEGRATION", "1"); + Ok(true) +} + +#[cfg(any(windows, test))] +fn command_prompt_with_shell_integration(original_prompt: &str) -> String { + format!("\x1b]133;D\x07\x1b]133;A\x07\x1b]0;ASHELL_CWD:$P\x07{original_prompt}\x1b]133;B\x07") +} + #[cfg(not(windows))] fn local_process_directory(system: &mut System, pid: Pid) -> Option { system.refresh_processes_specifics( @@ -214,45 +490,47 @@ pub fn spawn_local_terminal_at( let mut cmd = CommandBuilder::new(&launch.executable); #[cfg(windows)] { - const POWERSHELL_CWD_REPORTER: &str = r#"& { - $global:AshellOriginalPrompt = $function:prompt - function global:prompt { - $promptText = if ($global:AshellOriginalPrompt) { & $global:AshellOriginalPrompt } else { "PS $PWD> " } - $cwd = $PWD.ProviderPath - $encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cwd)) - [Console]::Write("$([char]27)]133;D$([char]7)$([char]27)]133;A$([char]7)") - [Console]::Write("$([char]27)]0;ASHELL_CWD_B64:$encoded$([char]7)") - "$promptText$([char]27)]133;B$([char]7)" - } - }"#; match shell { LocalTerminalShell::WindowsPowerShell | LocalTerminalShell::PowerShell7 => { - cmd.args(["-NoLogo", "-NoExit", "-Command", POWERSHELL_CWD_REPORTER]); + cmd.args([ + "-NoLogo", + "-NoExit", + "-Command", + POWERSHELL_SHELL_INTEGRATION, + ]); } LocalTerminalShell::CommandPrompt => { let original_prompt = std::env::var("PROMPT").unwrap_or_else(|_| "$P$G".to_string()); - let prompt = format!( - "\x1b]133;D\x07\x1b]133;A\x07\x1b]0;ASHELL_CWD:$P\x07{original_prompt}\x1b]133;B\x07" - ); cmd.args(["/Q"]); - cmd.env("PROMPT", prompt); + cmd.env( + "PROMPT", + command_prompt_with_shell_integration(&original_prompt), + ); } LocalTerminalShell::GitBash => { - let reporter = r#"printf '\033]133;D\a\033]133;A\a\033]0;ASHELL_CWD:%s\a' "$(pwd -W 2>/dev/null || pwd)""#; - let original_prompt_command = std::env::var("PROMPT_COMMAND").ok(); - let prompt_command = match original_prompt_command { - Some(command) if !command.trim().is_empty() => { - format!("{reporter};{command}") - } - _ => reporter.to_string(), - }; - cmd.args(["--login", "-i"]); cmd.env("CHERE_INVOKING", "1"); - cmd.env("PROMPT_COMMAND", prompt_command); } } } + let login_bash = cfg!(windows) && shell == LocalTerminalShell::GitBash; + let shell_integration_configured = + match configure_file_shell_integration(&mut cmd, &launch.executable, login_bash) { + Ok(configured) => configured, + Err(error) => { + tracing::warn!( + "[local] shell integration unavailable for {}: {error:#}", + launch.executable.display() + ); + false + } + }; + #[cfg(windows)] + if shell == LocalTerminalShell::GitBash && !shell_integration_configured { + cmd.args(["--login", "-i"]); + } + #[cfg(not(windows))] + let _ = shell_integration_configured; cmd.env( "TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()), @@ -394,3 +672,73 @@ pub fn spawn_local_terminal_at( Ok(BackendTx::Local(cmd_tx)) } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{ + BASH_INTEGRATION_RC, FISH_INTEGRATION_COMMAND, FileShellIntegration, + POWERSHELL_SHELL_INTEGRATION, ZSH_ENV_WRAPPER, ZSH_INTEGRATION_RC, + command_prompt_with_shell_integration, file_shell_integration, + }; + + #[test] + fn recognizes_supported_file_based_shells_on_unix_and_windows() { + assert_eq!( + file_shell_integration(Path::new("/bin/bash"), false), + Some(FileShellIntegration::Bash { login: false }) + ); + assert_eq!( + file_shell_integration(Path::new("bash.exe"), true), + Some(FileShellIntegration::Bash { login: true }) + ); + assert_eq!( + file_shell_integration(Path::new("/bin/zsh"), false), + Some(FileShellIntegration::Zsh) + ); + assert_eq!( + file_shell_integration(Path::new("/opt/homebrew/bin/fish"), false), + Some(FileShellIntegration::Fish) + ); + assert_eq!(file_shell_integration(Path::new("pwsh.exe"), false), None); + } + + #[test] + fn shell_integrations_mark_prompt_and_command_boundaries() { + for integration in [ + BASH_INTEGRATION_RC, + ZSH_INTEGRATION_RC, + FISH_INTEGRATION_COMMAND, + POWERSHELL_SHELL_INTEGRATION, + ] { + assert!(integration.contains("133;A")); + assert!(integration.contains("133;B")); + assert!(integration.contains("133;C") || integration.contains("133;D")); + } + assert!(BASH_INTEGRATION_RC.contains("133;A;k=s")); + assert!(ZSH_INTEGRATION_RC.contains("133;A;k=s")); + assert!(POWERSHELL_SHELL_INTEGRATION.contains("133;A;k=s")); + + let cmd_prompt = command_prompt_with_shell_integration("$P$G"); + assert!(cmd_prompt.contains("133;A")); + assert!(cmd_prompt.contains("133;B")); + assert!(cmd_prompt.contains("ASHELL_CWD:$P")); + } + + #[test] + fn startup_wrappers_load_user_configuration_before_installing_hooks() { + assert!(BASH_INTEGRATION_RC.contains("$HOME/.bashrc")); + assert!(BASH_INTEGRATION_RC.contains("$HOME/.bash_profile")); + assert!(ZSH_ENV_WRAPPER.contains("$ASHELL_USER_ZDOTDIR/.zshenv")); + assert!(ZSH_INTEGRATION_RC.contains("$ASHELL_USER_ZDOTDIR/.zshrc")); + } + + #[test] + fn zsh_integration_restores_user_history_path() { + assert!(ZSH_INTEGRATION_RC.contains(r#"${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/*"#)); + assert!(ZSH_INTEGRATION_RC.contains( + r#"HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}""# + )); + } +} diff --git a/src/desktop_notification.rs b/src/desktop_notification.rs index 0b3e2cb..8b98e18 100644 --- a/src/desktop_notification.rs +++ b/src/desktop_notification.rs @@ -183,6 +183,28 @@ pub(crate) fn clear_unread_indicator(window_handle: Option) { set_unread_indicator(false, window_handle); } +#[allow(deprecated)] +/// Clears notifications delivered by the current application from Notification Center. +pub(crate) fn clear_current_app_delivered_notifications() { + #[cfg(target_os = "macos")] + { + use objc2_foundation::NSUserNotificationCenter; + + // The default center is scoped to this application, so other apps' notifications remain untouched. + NSUserNotificationCenter::defaultUserNotificationCenter().removeAllDeliveredNotifications(); + } + + #[cfg(target_os = "windows")] + { + use windows::UI::Notifications::ToastNotificationManager; + + if let Err(error) = ToastNotificationManager::History().and_then(|history| history.Clear()) + { + tracing::warn!("failed to clear Windows notification history: {error}"); + } + } +} + #[cfg(target_os = "windows")] fn set_windows_taskbar_badge(window_handle: isize, unread: bool) -> windows::core::Result<()> { use windows::{ diff --git a/src/session/config.rs b/src/session/config.rs index 1470706..c0a2a22 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -1216,7 +1216,6 @@ impl ConfigStore { self.cache.workspace_panels.as_ref() } - #[allow(dead_code)] pub fn body_panels(&self) -> Option<&Vec> { self.cache.body_panels.as_ref() } diff --git a/src/session/mod.rs b/src/session/mod.rs index 3182532..bf8bafe 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -432,11 +432,7 @@ impl Ashell { self.tabs.retain(|tab| tab.id != tab_id); restored_tab_ids.remove(&tab_id); } - if pane_root - .tab_ids() - .first() - .is_none_or(|tab_id| tab_id.is_empty()) - { + if pane_root.first_tab_id().is_none_or(str::is_empty) { continue; } @@ -470,7 +466,7 @@ impl Ashell { self.pane_root = active_layout; let active_tab = requested_active_tab .filter(|id| self.pane_root.contains(id)) - .or_else(|| self.pane_root.tab_ids().first().map(|id| (*id).to_string())); + .or_else(|| self.pane_root.first_tab_id().map(str::to_string)); if let Some(active_tab) = active_tab { self.focus_pane_with_id(active_tab); } @@ -1917,16 +1913,12 @@ impl Ashell { if pos > 0 { next_active_id = all_groups[pos - 1] .pane_root - .tab_ids() - .first() - .copied() + .first_tab_id() .map(String::from); } else if pos + 1 < all_groups.len() { next_active_id = all_groups[pos + 1] .pane_root - .tab_ids() - .first() - .copied() + .first_tab_id() .map(String::from); } } @@ -1942,8 +1934,8 @@ impl Ashell { .collect(); for tab_id in &tab_ids { if let Some(ix) = self.tabs.iter().position(|tab| tab.id == *tab_id) { - self.tabs[ix].send_backend(BackendCommand::Close); - self.tabs.retain(|t| t.id != *tab_id); + let tab = self.tabs.remove(ix); + tab.send_backend(BackendCommand::Close); } } if let Some(handle) = self.sftp_handles.remove(&group.id) { @@ -1954,8 +1946,8 @@ impl Ashell { } else { // Just remove this tab from the group if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) { - self.tabs[ix].send_backend(BackendCommand::Close); - self.tabs.retain(|t| t.id != id); + let tab = self.tabs.remove(ix); + tab.send_backend(BackendCommand::Close); } if let Some(g) = self .tab_groups @@ -1976,18 +1968,7 @@ impl Ashell { self.tab_groups.clear(); self.tabs.clear(); self.system_tab_id = None; - self.cpu_history.clear(); - self.net_rx_history.clear(); - self.net_tx_history.clear(); - self.remote_processes.clear(); - self.remote_ports.clear(); - self.terminating_processes.clear(); - self.remote_process_status = None; - self.remote_ports_status = None; - self.remote_processes_in_flight = false; - self.remote_ports_in_flight = false; - self.expanded_process_pid = None; - self.system_status = None; + self.reset_system_monitor_state(); self.show_command_history = false; self.selected_command_history.clear(); for (_, handle) in self.sftp_handles.drain() { @@ -2008,9 +1989,7 @@ impl Ashell { // Activate next available pane let new_id = next_active_id.or_else(|| { self.pane_root - .tab_ids() - .first() - .copied() + .first_tab_id() .map(String::from) .or_else(|| self.tabs.first().map(|t| t.id.clone())) }); @@ -2091,6 +2070,14 @@ impl Ashell { } } } + if event.modifiers.alt + && !event.modifiers.control + && !event.modifiers.shift + && !event.modifiers.platform + && self.move_terminal_cursor_to_click(event.position, window, cx) + { + return; + } if self.config.right_click_copy_paste() { if let Some(text) = self.active_terminal_selection_text() { if !text.is_empty() { @@ -2462,8 +2449,7 @@ impl Ashell { if let Some(group) = self.tab_groups.iter().find(|g| g.id == group_id) { self.pane_root = group.pane_root.clone(); self.active_group = Some(group_id); - let ids = group.pane_root.tab_ids(); - if let Some(&first_id) = ids.first() { + if let Some(first_id) = group.pane_root.first_tab_id() { self.active_tab = Some(first_id.to_string()); self.focus_pane_with_id(first_id.to_string()); } @@ -2610,19 +2596,7 @@ impl Ashell { if self.system_tab_id != new_id { self.system_tab_id = new_id; - self.system = crate::system::SystemSnapshot::default(); - self.cpu_history.clear(); - self.net_rx_history.clear(); - self.net_tx_history.clear(); - self.remote_processes.clear(); - self.remote_ports.clear(); - self.terminating_processes.clear(); - self.remote_sample_in_flight = false; - self.remote_processes_in_flight = false; - self.remote_ports_in_flight = false; - self.remote_process_status = None; - self.remote_ports_status = None; - self.expanded_process_pid = None; + self.reset_system_monitor_state(); if let Some(status) = active_ssh_status { self.system_status = Some(status.clone().into()); self.remote_process_status = Some(status.into()); @@ -2639,25 +2613,13 @@ impl Ashell { } } - pub(crate) fn start_drag_split( - &mut self, - parent_path: Vec, - child_index: usize, - event: &MouseDownEvent, - _window: &mut Window, - _cx: &mut Context, - ) { - self.dragging_splitter = Some((parent_path, child_index)); + pub(crate) fn start_drag_split(&mut self, parent_path: Vec, event: &MouseDownEvent) { + self.dragging_splitter = Some(parent_path); self.drag_split_origin = Some(event.position); } - pub(crate) fn on_split_drag_move( - &mut self, - event: &MouseMoveEvent, - window: &mut Window, - _cx: &mut Context, - ) { - let Some((ref parent_path, child_idx)) = self.dragging_splitter.clone() else { + pub(crate) fn on_split_drag_move(&mut self, event: &MouseMoveEvent, window: &mut Window) { + let Some(parent_path) = self.dragging_splitter.as_deref() else { return; }; let Some(origin) = self.drag_split_origin else { @@ -2679,7 +2641,7 @@ impl Ashell { return; // dead zone } let ratio_delta = delta / total_size; - Self::adjust_split_ratio(&mut self.pane_root, parent_path, child_idx, ratio_delta); + Self::adjust_split_ratio(&mut self.pane_root, parent_path, ratio_delta); self.drag_split_origin = Some(event.position); self.sync_pane_root_to_group(); } @@ -2704,7 +2666,7 @@ impl Ashell { } } - fn adjust_split_ratio(layout: &mut PaneLayout, path: &[usize], _child_idx: usize, delta: f32) { + fn adjust_split_ratio(layout: &mut PaneLayout, path: &[usize], delta: f32) { if let PaneLayout::Horizontal(children, ratio) | PaneLayout::Vertical(children, ratio) = layout { @@ -2713,7 +2675,7 @@ impl Ashell { } else { let (&first, rest) = path.split_first().unwrap(); if let Some(child) = children.get_mut(first) { - Self::adjust_split_ratio(child, rest, _child_idx, delta); + Self::adjust_split_ratio(child, rest, delta); } } } diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index 04536ad..68f03ed 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -26,7 +26,6 @@ use tokio::{ mpsc::{self, UnboundedReceiver, UnboundedSender}, oneshot, }, - task::JoinHandle, }; use uuid::Uuid; use walkdir::WalkDir; @@ -107,7 +106,6 @@ pub struct TransferStateFlag(pub Arc); struct TransferContext<'a> { flag: &'a TransferStateFlag, events: &'a std::sync::mpsc::Sender, - tab_id: &'a str, id: &'a str, } @@ -129,7 +127,6 @@ impl TransferStateFlag { pub async fn yield_if_paused( &self, events: &std::sync::mpsc::Sender, - tab_id: &str, id: &str, transferred: u64, total: Option, @@ -143,7 +140,6 @@ impl TransferStateFlag { if state == 1 { if !was_paused { let _ = events.send(crate::terminal::BackendEvent::TransferProgress { - tab_id: tab_id.to_string(), id: id.to_string(), transferred, total, @@ -155,7 +151,6 @@ impl TransferStateFlag { } else { if was_paused { let _ = events.send(crate::terminal::BackendEvent::TransferProgress { - tab_id: tab_id.to_string(), id: id.to_string(), transferred, total, @@ -168,19 +163,9 @@ impl TransferStateFlag { } } +#[derive(Clone)] pub struct SftpHandle { pub commands: UnboundedSender, - #[allow(dead_code)] - join: Option>, -} - -impl Clone for SftpHandle { - fn clone(&self) -> Self { - Self { - commands: self.commands.clone(), - join: None, - } - } } impl SftpHandle { @@ -269,7 +254,7 @@ pub fn spawn_sftp( ) -> SftpHandle { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let cmd_tx_clone = cmd_tx.clone(); - let join = runtime.spawn(async move { + drop(runtime.spawn(async move { if let Err(err) = run_sftp( tab_id.clone(), session, @@ -284,11 +269,8 @@ pub fn spawn_sftp( text: format!("sftp error: {err:#}"), }); } - }); - SftpHandle { - commands: cmd_tx, - join: Some(join), - } + })); + SftpHandle { commands: cmd_tx } } async fn run_sftp( @@ -411,7 +393,6 @@ async fn run_sftp( let transfer = TransferContext { flag: &flag, events: &events_clone, - tab_id: &tab_id_clone, id: &id, }; download_path_impl( @@ -443,7 +424,7 @@ async fn run_sftp( crate::terminal::TransferState::Failed(err_msg.clone()) }; let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), + tab_id: tab_id_clone, text: if is_cancelled { "Transmission cancelled".to_string() } else { @@ -451,7 +432,6 @@ async fn run_sftp( }, }); let _ = events_clone.send(BackendEvent::TransferProgress { - tab_id: tab_id_clone, id: id.clone(), transferred: 0, total: None, @@ -524,7 +504,6 @@ async fn run_sftp( &remote_dir, flag, &events_clone, - &tab_id_clone, &id, ) .await @@ -534,7 +513,7 @@ async fn run_sftp( match result { Ok(summary) => { let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), + tab_id: tab_id_clone, text: summary, }); let _ = commands_tx_clone.send(SftpCommand::ListDir(remote_dir)); @@ -550,7 +529,7 @@ async fn run_sftp( crate::terminal::TransferState::Failed(err_msg.clone()) }; let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), + tab_id: tab_id_clone, text: if is_cancelled { "Transmission cancelled".to_string() } else { @@ -558,7 +537,6 @@ async fn run_sftp( }, }); let _ = events_clone.send(BackendEvent::TransferProgress { - tab_id: tab_id_clone, id: id.clone(), transferred: 0, total: None, @@ -1467,13 +1445,7 @@ async fn download_file_impl( loop { transfer .flag - .yield_if_paused( - transfer.events, - transfer.tab_id, - transfer.id, - transferred, - total, - ) + .yield_if_paused(transfer.events, transfer.id, transferred, total) .await?; let read = remote_file .read(&mut buffer) @@ -1489,7 +1461,6 @@ async fn download_file_impl( transferred += read as u64; let _ = transfer.events.send(BackendEvent::TransferProgress { - tab_id: transfer.tab_id.to_string(), id: transfer.id.to_string(), transferred, total, @@ -1499,7 +1470,6 @@ async fn download_file_impl( local_file.flush().await.context("flush local file")?; let _ = transfer.events.send(BackendEvent::TransferProgress { - tab_id: transfer.tab_id.to_string(), id: transfer.id.to_string(), transferred, total, @@ -1515,7 +1485,6 @@ async fn upload_paths_impl( remote_dir: &str, flag: TransferStateFlag, events: &std::sync::mpsc::Sender, - tab_id: &str, id: &str, ) -> Result { // Check for cancellation before starting @@ -1598,7 +1567,6 @@ async fn upload_paths_impl( for (local_path, remote_path) in files_to_upload { let flag_clone = TransferStateFlag(Arc::clone(&flag.0)); let events_clone = events.clone(); - let tab_id_clone = tab_id.to_string(); let id_clone = id.to_string(); let transferred_clone = Arc::clone(&transferred); @@ -1606,7 +1574,6 @@ async fn upload_paths_impl( let transfer = TransferContext { flag: &flag_clone, events: &events_clone, - tab_id: &tab_id_clone, id: &id_clone, }; upload_file_impl( @@ -1628,7 +1595,6 @@ async fn upload_paths_impl( } let _ = events.send(BackendEvent::TransferProgress { - tab_id: tab_id.to_string(), id: id.to_string(), transferred: total_bytes, total: Some(total_bytes), @@ -1675,7 +1641,7 @@ async fn upload_file_impl( let cur = transferred.load(Ordering::Relaxed); transfer .flag - .yield_if_paused(transfer.events, transfer.tab_id, transfer.id, cur, total) + .yield_if_paused(transfer.events, transfer.id, cur, total) .await?; let read = local.read(&mut buffer).await.context("read local file")?; if read == 0 { @@ -1688,7 +1654,6 @@ async fn upload_file_impl( let new_cur = transferred.fetch_add(read as u64, Ordering::Relaxed) + read as u64; let _ = transfer.events.send(BackendEvent::TransferProgress { - tab_id: transfer.tab_id.to_string(), id: transfer.id.to_string(), transferred: new_cur, total, diff --git a/src/system/mod.rs b/src/system/mod.rs index e43f4b3..a2f80ac 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -123,24 +123,16 @@ impl SystemSampler { total_bytes: disk.total_space(), }) .collect(); - disks.sort_by(|a, b| { - if a.mount == "/" { - return std::cmp::Ordering::Less; - } - if b.mount == "/" { - return std::cmp::Ordering::Greater; - } - a.mount.cmp(&b.mount) - }); + sort_disks(&mut disks); SystemSnapshot { cpu_percent, mem_percent: ratio(mem_used, mem_total), swap_percent: ratio(swap_used, swap_total), - mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)), - swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)), - net_rx: format!("{}/s", format_bytes(rx_rate)), - net_tx: format!("{}/s", format_bytes(tx_rate)), + mem_detail: format_usage(mem_used, mem_total), + swap_detail: format_usage(swap_used, swap_total), + net_rx: format_rate(rx_rate), + net_tx: format_rate(tx_rate), net_rx_rate: rx_rate, net_tx_rate: tx_rate, disks, @@ -172,6 +164,22 @@ pub fn format_bytes(bytes: u64) -> String { } } +fn format_usage(used: u64, total: u64) -> String { + format!("{}/{}", format_bytes(used), format_bytes(total)) +} + +fn format_rate(bytes: u64) -> String { + format!("{}/s", format_bytes(bytes)) +} + +fn sort_disks(disks: &mut [DiskSample]) { + disks.sort_by(|a, b| { + (a.mount != "/") + .cmp(&(b.mount != "/")) + .then_with(|| a.mount.cmp(&b.mount)) + }); +} + pub fn remote_snapshot_from_kv(raw: &str) -> Result { let mut kv = BTreeMap::new(); let mut disks = Vec::new(); @@ -224,24 +232,16 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result { // (catches any virtual fs lines that slipped past the script filter) disks.retain(|d| d.total_bytes >= 1024 * 1024); - disks.sort_by(|a, b| { - if a.mount == "/" { - return std::cmp::Ordering::Less; - } - if b.mount == "/" { - return std::cmp::Ordering::Greater; - } - a.mount.cmp(&b.mount) - }); + sort_disks(&mut disks); Ok(SystemSnapshot { cpu_percent: cpu_percent.clamp(0.0, 1.0), mem_percent: ratio(mem_used, mem_total), swap_percent: ratio(swap_used, swap_total), - mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)), - swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)), - net_rx: format!("{}/s", format_bytes(rx_rate)), - net_tx: format!("{}/s", format_bytes(tx_rate)), + mem_detail: format_usage(mem_used, mem_total), + swap_detail: format_usage(swap_used, swap_total), + net_rx: format_rate(rx_rate), + net_tx: format_rate(tx_rate), net_rx_rate: rx_rate, net_tx_rate: tx_rate, disks, @@ -378,13 +378,14 @@ mod tests { #[test] fn clamps_remote_resource_values_to_valid_ranges() { let snapshot = remote_snapshot_from_kv( - "CPU_PERCENT=250\nMEM_TOTAL=100\nMEM_USED=150\nSWAP_TOTAL=0\nSWAP_USED=10\nNET_RX=12\nNET_TX=34\nDISK=/\t3145728\t2097152\n", + "CPU_PERCENT=250\nMEM_TOTAL=100\nMEM_USED=150\nSWAP_TOTAL=0\nSWAP_USED=10\nNET_RX=12\nNET_TX=34\nDISK=/data\t1048576\t2097152\nDISK=/\t3145728\t2097152\n", ) .unwrap(); assert_eq!(snapshot.cpu_percent, 1.0); assert_eq!(snapshot.mem_percent, 1.0); assert_eq!(snapshot.swap_percent, 0.0); + assert_eq!(snapshot.disks[0].mount, "/"); assert_eq!(snapshot.disks[0].available_bytes, 2097152); } diff --git a/src/terminal/input.rs b/src/terminal/input.rs index b9c595b..1fb2a5e 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -2,6 +2,7 @@ use std::ops::Range; use alacritty_terminal::index::Side; use alacritty_terminal::selection::SelectionType; +use alacritty_terminal::term::{TermMode, cell::Flags}; use gpui::{ ClipboardItem, Context, Focusable as _, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px, @@ -324,6 +325,7 @@ impl Ashell { } tab.clear_selection(); self.terminal_marked_text = None; + tab.record_terminal_input(&bytes); let encoded = tab.encode_input(&bytes); tab.send_backend(BackendCommand::Input(encoded)); window.invalidate_character_coordinates(); @@ -351,6 +353,7 @@ impl Ashell { if is_alternate_screen_active { self.ssh_command_buffers.remove(tab_id); self.ssh_command_starts.remove(tab_id); + self.ssh_command_input_uncertain.remove(tab_id); return; } @@ -358,6 +361,7 @@ impl Ashell { let edits_command = bytes .iter() .any(|byte| !matches!(*byte, b'\r' | b'\n' | b'\x03')); + let mut input_uncertain = self.ssh_command_input_uncertain.contains(tab_id); if edits_command && !self.ssh_command_starts.contains_key(tab_id) { if let Some(cursor) = cursor { self.ssh_command_starts.insert(tab_id.to_string(), cursor); @@ -373,7 +377,7 @@ impl Ashell { .iter() .find(|tab| tab.id == tab_id) .map(|tab| tab.render_snapshot(false)) - .and_then(|snapshot| terminal_command_text(&snapshot, start)) + .and_then(|snapshot| terminal_command_text(&snapshot, start, cursor)) }) } else { None @@ -405,15 +409,22 @@ impl Ashell { continue; } match character { - '\x1b' => in_escape = true, + '\x1b' => { + input_uncertain = true; + in_escape = true; + } '\r' | '\n' => { - let command = - merge_command_text(rendered_command.take().as_deref(), buffer); + let command = command_history_text( + rendered_command.take().as_deref(), + buffer, + input_uncertain, + ); if !command.is_empty() { completed.push(command); } buffer.clear(); reset_command_start = true; + input_uncertain = false; } '\u{8}' | '\u{7f}' => { buffer.pop(); @@ -422,6 +433,7 @@ impl Ashell { '\u{3}' => { buffer.clear(); reset_command_start = true; + input_uncertain = false; } '\u{17}' => { let trimmed_len = buffer.trim_end().len(); @@ -434,12 +446,17 @@ impl Ashell { } } character if !character.is_control() => buffer.push(character), - _ => {} + _ => input_uncertain = true, } } } if reset_command_start { self.ssh_command_starts.remove(tab_id); + self.ssh_command_input_uncertain.remove(tab_id); + } else if input_uncertain { + self.ssh_command_input_uncertain.insert(tab_id.to_string()); + } else { + self.ssh_command_input_uncertain.remove(tab_id); } let mut changed = false; @@ -514,6 +531,124 @@ impl Ashell { } } + pub(crate) fn move_terminal_cursor_to_click( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some((target_row, target_col, _)) = self.terminal_grid_point_and_side(position) else { + return false; + }; + let Some(active_id) = self.active_tab.clone() else { + return false; + }; + let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else { + return false; + }; + let mode = *tab.term.mode(); + let click_state = if tab.is_alternate_screen_active() || tab.mouse_tracking_enabled() { + None + } else { + tab.prompt_input_click_state((target_row, target_col)) + }; + let (bytes, predicted_cursor) = if let Some(click_state) = click_state { + if !prompt_click_is_valid((target_row, target_col), click_state.command_start) { + return false; + } + match click_state.mode { + crate::terminal::PromptClickMode::Absolute => ( + sgr_prompt_click( + (target_row, target_col), + click_state.prompt_row_offset, + click_state.mode, + ), + None, + ), + crate::terminal::PromptClickMode::Relative if click_state.relative_click_valid => ( + sgr_prompt_click( + (target_row, target_col), + click_state.prompt_row_offset, + click_state.mode, + ), + None, + ), + crate::terminal::PromptClickMode::Relative + | crate::terminal::PromptClickMode::TerminalManaged => { + let Some(cursor) = tab.cursor_state_for_click() else { + return false; + }; + let snapshot = tab.render_snapshot(false); + let movement = prompt_cursor_move( + &snapshot, + cursor, + (target_row, target_col), + &click_state.command_starts, + tab.app_cursor_mode(), + ); + let predicted_cursor = crate::terminal::CursorState { + row: movement.target.0, + col: movement.target.1, + shape: cursor.shape, + }; + (movement.bytes, Some(predicted_cursor)) + } + } + } else if tab.mouse_tracking_enabled() { + (terminal_mouse_click((target_row, target_col), mode), None) + } else if tab.is_alternate_screen_active() { + let Some(cursor) = tab.cursor_state_for_click() else { + return false; + }; + let snapshot = tab.render_snapshot(false); + let movement = alternate_screen_cursor_move( + &snapshot, + cursor, + (target_row, target_col), + tab.app_cursor_mode(), + ); + let predicted_cursor = crate::terminal::CursorState { + row: movement.target.0, + col: movement.target.1, + shape: cursor.shape, + }; + (movement.bytes, Some(predicted_cursor)) + } else { + let Some(cursor) = tab.cursor_state_for_click() else { + return false; + }; + let snapshot = tab.render_snapshot(false); + let movement = prompt_cursor_move( + &snapshot, + cursor, + (target_row, target_col), + &[], + tab.app_cursor_mode(), + ); + let predicted_cursor = crate::terminal::CursorState { + row: movement.target.0, + col: movement.target.1, + shape: cursor.shape, + }; + (movement.bytes, Some(predicted_cursor)) + }; + tab.clear_selection(); + if let Some(predicted_cursor) = predicted_cursor { + if !bytes.is_empty() { + tab.note_click_cursor_move(predicted_cursor); + } + } else { + tab.clear_click_cursor_prediction(); + } + if !bytes.is_empty() { + tab.send_backend(crate::terminal::BackendCommand::Input(bytes)); + } + window.prevent_default(); + cx.stop_propagation(); + cx.notify(); + true + } + pub(crate) fn on_terminal_mouse_move( &mut self, event: &MouseMoveEvent, @@ -523,7 +658,7 @@ impl Ashell { // Handle split drag if self.dragging_splitter.is_some() { if event.pressed_button == Some(MouseButton::Left) { - self.on_split_drag_move(event, window, cx); + self.on_split_drag_move(event, window); cx.notify(); } else { self.end_drag_split(); @@ -654,9 +789,13 @@ impl Ashell { let line_height = px(self.terminal_line_height()); let snapshot = self.active_snapshot()?; let max_col = snapshot.cols.saturating_sub(1); - let max_row = snapshot.rows.saturating_sub(1); let col = ((local_x / cell_width).floor() as usize).min(max_col); - let row = ((local_y / line_height).floor() as usize).min(max_row); + let row = terminal_grid_row( + local_y.as_f32(), + bounds.size.height.as_f32(), + line_height.as_f32(), + snapshot.rows, + ); let cell_offset_x = px(local_x.as_f32() % cell_width.as_f32()); let side = if cell_offset_x >= (cell_width / 2.) { Side::Right @@ -714,6 +853,7 @@ impl Ashell { return; } + tab.clear_click_cursor_prediction(); let mode = tab.term.mode(); let is_mouse_tracking = mode.intersects( @@ -777,9 +917,372 @@ impl Ashell { } } +fn terminal_grid_row( + local_y: f32, + container_height: f32, + line_height: f32, + row_count: usize, +) -> usize { + let grid_height = (container_height / line_height).floor().max(1.0) * line_height; + let y_offset = ((container_height - grid_height) / 2.0).max(0.0); + (((local_y - y_offset).max(0.0) / line_height).floor() as usize) + .min(row_count.saturating_sub(1)) +} + +fn prompt_click_is_valid(target: (usize, usize), command_start: (usize, usize)) -> bool { + target.0 > command_start.0 || (target.0 == command_start.0 && target.1 >= command_start.1) +} + +fn append_cursor_key(bytes: &mut Vec, key: u8, app_cursor_mode: bool) { + bytes.extend_from_slice(&crate::terminal::encode_cursor_key(key, app_cursor_mode)); +} + +fn snapshot_cell_widths(snapshot: &crate::terminal::RenderSnapshot) -> Vec { + let mut cell_widths = vec![1usize; snapshot.rows.saturating_mul(snapshot.cols)]; + for render_cell in &snapshot.cells { + let Ok(row) = usize::try_from(render_cell.row) else { + continue; + }; + let Ok(col) = usize::try_from(render_cell.col) else { + continue; + }; + if row >= snapshot.rows || col >= snapshot.cols { + continue; + } + + let flags = render_cell.cell.flags; + cell_widths[row * snapshot.cols + col] = + if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + 0 + } else if flags.contains(Flags::WIDE_CHAR) { + 2 + } else { + 1 + }; + } + cell_widths +} + +fn snap_cursor_col(cell_widths: &[usize], row: usize, col: usize, cols: usize) -> usize { + let mut col = col.min(cols.saturating_sub(1)); + while col > 0 && cell_widths.get(row * cols + col).copied() == Some(0) { + col -= 1; + } + col +} + +fn horizontal_cursor_move_count( + cell_widths: &[usize], + row: usize, + source_col: usize, + target_col: usize, + cols: usize, +) -> usize { + if source_col == target_col || cols == 0 { + return 0; + } + + let mut count = 0; + let mut col = source_col.min(cols - 1); + if col < target_col { + while col < target_col { + col += 1; + while col < target_col && cell_widths.get(row * cols + col).copied() == Some(0) { + col += 1; + } + count += 1; + } + } else { + while col > target_col { + col -= 1; + while col > target_col && cell_widths.get(row * cols + col).copied() == Some(0) { + col -= 1; + } + count += 1; + } + } + count +} + +fn append_horizontal_cursor_move( + bytes: &mut Vec, + cell_widths: &[usize], + row: usize, + source_col: usize, + target_col: usize, + cols: usize, + app_cursor_mode: bool, +) { + let key = if target_col < source_col { b'D' } else { b'C' }; + let count = horizontal_cursor_move_count(cell_widths, row, source_col, target_col, cols); + for _ in 0..count { + append_cursor_key(bytes, key, app_cursor_mode); + } +} + +#[derive(Debug, PartialEq, Eq)] +struct CursorMove { + bytes: Vec, + target: (usize, usize), +} + +fn alternate_screen_cursor_move( + snapshot: &crate::terminal::RenderSnapshot, + cursor: crate::terminal::CursorState, + target: (usize, usize), + app_cursor_mode: bool, +) -> CursorMove { + // Mouse-aware apps bypass this helper and receive exact cell coordinates. + // Otherwise mirror iTerm2's predictive fallback; rendered cells no longer + // retain enough information to distinguish tabs from literal spaces. + if snapshot.rows == 0 || snapshot.cols == 0 { + return CursorMove { + bytes: Vec::new(), + target: (cursor.row, cursor.col), + }; + } + + let cell_widths = snapshot_cell_widths(snapshot); + let cursor_row = cursor.row.min(snapshot.rows - 1); + let mut position = ( + cursor_row, + snap_cursor_col(&cell_widths, cursor_row, cursor.col, snapshot.cols), + ); + let target_row = target.0.min(snapshot.rows - 1); + let target = ( + target_row, + snap_cursor_col(&cell_widths, target_row, target.1, snapshot.cols), + ); + if position == target { + return CursorMove { + bytes: Vec::new(), + target, + }; + } + + let estimated_moves = position.0.abs_diff(target.0) + position.1.abs_diff(target.1); + let mut bytes = Vec::with_capacity(estimated_moves.saturating_mul(3)); + + // Match iTerm2's ordering so vertical movement cannot clamp a cursor that + // first needs to move left to reach the requested column. + if position.1 > target.1 { + let pre_vertical_col = snap_cursor_col(&cell_widths, position.0, target.1, snapshot.cols); + append_horizontal_cursor_move( + &mut bytes, + &cell_widths, + position.0, + position.1, + pre_vertical_col, + snapshot.cols, + app_cursor_mode, + ); + position.1 = pre_vertical_col; + } + + let vertical_key = if target.0 < position.0 { b'A' } else { b'B' }; + for _ in 0..position.0.abs_diff(target.0) { + append_cursor_key(&mut bytes, vertical_key, app_cursor_mode); + } + position.0 = target.0; + position.1 = snap_cursor_col(&cell_widths, position.0, position.1, snapshot.cols); + + if position.1 != target.1 { + append_horizontal_cursor_move( + &mut bytes, + &cell_widths, + position.0, + position.1, + target.1, + snapshot.cols, + app_cursor_mode, + ); + } + + CursorMove { bytes, target } +} + +fn prompt_cursor_move( + snapshot: &crate::terminal::RenderSnapshot, + cursor: crate::terminal::CursorState, + target: (usize, usize), + command_starts: &[(usize, usize)], + app_cursor_mode: bool, +) -> CursorMove { + if snapshot.rows == 0 || snapshot.cols == 0 { + return CursorMove { + bytes: Vec::new(), + target: (cursor.row, cursor.col), + }; + } + + let cell_widths = snapshot_cell_widths(snapshot); + let target_row = target.0.min(snapshot.rows.saturating_sub(1)); + let target = ( + target_row, + snap_cursor_col(&cell_widths, target_row, target.1, snapshot.cols), + ); + let cursor_row = cursor.row.min(snapshot.rows.saturating_sub(1)); + let cursor_point = ( + cursor_row, + snap_cursor_col(&cell_widths, cursor_row, cursor.col, snapshot.cols), + ); + if target == cursor_point { + return CursorMove { + bytes: Vec::new(), + target, + }; + } + + let (start, end, key) = if target < cursor_point { + (target, cursor_point, b'D') + } else { + (cursor_point, target, b'C') + }; + let mut row_text_starts: Vec> = vec![None; snapshot.rows]; + let mut row_text_ends: Vec> = vec![None; snapshot.rows]; + let mut row_wraps = vec![false; snapshot.rows]; + for render_cell in &snapshot.cells { + let Ok(row) = usize::try_from(render_cell.row) else { + continue; + }; + let Ok(col) = usize::try_from(render_cell.col) else { + continue; + }; + if row >= snapshot.rows || col >= snapshot.cols { + continue; + } + let flags = render_cell.cell.flags; + row_wraps[row] |= flags.contains(Flags::WRAPLINE); + if !flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + let width = cell_widths[row * snapshot.cols + col]; + if render_cell.cell.c != ' ' + || render_cell + .cell + .zerowidth() + .is_some_and(|characters| !characters.is_empty()) + { + row_text_starts[row] = + Some(row_text_starts[row].map_or(col, |start| start.min(col))); + row_text_ends[row] = Some( + row_text_ends[row] + .map_or(col.saturating_add(width), |end| { + end.max(col.saturating_add(width)) + }) + .min(snapshot.cols), + ); + } + } + } + + // Spaces between two cursor positions are real input positions. Only trim + // the unused margin of an explicitly-broken row; wrapped rows consume the + // complete terminal width. + let mut count = 0; + for row in start.0..=end.0 { + let mut col = if row == start.0 { + start.1 + } else { + command_starts + .iter() + .rev() + .find(|(command_row, _)| *command_row == row) + .map(|(_, command_col)| *command_col) + .or_else(|| { + if row > 0 && row_wraps[row - 1] { + Some(0) + } else { + row_text_starts[row] + } + }) + .unwrap_or(0) + }; + let col_limit = if row == end.0 { + end.1.min(snapshot.cols) + } else if row_wraps[row] { + snapshot.cols + } else { + row_text_ends[row].unwrap_or(col) + }; + while col < col_limit { + let width = cell_widths[row * snapshot.cols + col]; + if width == 0 { + col += 1; + continue; + } + count += 1; + col = col.saturating_add(width); + } + if row < end.0 && !row_wraps[row] { + count += 1; + } + } + + let mut bytes = Vec::with_capacity(count * 3); + for _ in 0..count { + append_cursor_key(&mut bytes, key, app_cursor_mode); + } + CursorMove { bytes, target } +} + +fn sgr_prompt_click( + target: (usize, usize), + prompt_row_offset: usize, + click_mode: crate::terminal::PromptClickMode, +) -> Vec { + let row = match click_mode { + crate::terminal::PromptClickMode::Absolute + | crate::terminal::PromptClickMode::TerminalManaged => target.0 + 1, + crate::terminal::PromptClickMode::Relative => prompt_row_offset + 1, + }; + format!("\x1b[<0;{};{}M", target.1 + 1, row).into_bytes() +} + +fn terminal_mouse_click(target: (usize, usize), mode: TermMode) -> Vec { + if mode.contains(TermMode::SGR_MOUSE) { + return format!( + "\x1b[<0;{};{}M\x1b[<0;{};{}m", + target.1 + 1, + target.0 + 1, + target.1 + 1, + target.0 + 1 + ) + .into_bytes(); + } + + let utf8 = mode.contains(TermMode::UTF8_MOUSE); + let max_point = if utf8 { 2015 } else { 223 }; + if target.0 >= max_point || target.1 >= max_point { + return Vec::new(); + } + let mut bytes = Vec::with_capacity(12); + for button in [0u8, 3u8] { + bytes.extend_from_slice(b"\x1b[M"); + bytes.push(32 + button); + append_mouse_coordinate(&mut bytes, target.1, utf8); + append_mouse_coordinate(&mut bytes, target.0, utf8); + } + bytes +} + +fn append_mouse_coordinate(bytes: &mut Vec, position: usize, utf8: bool) { + let encoded = position + 33; + if utf8 && position >= 95 { + let mut buffer = [0; 4]; + bytes.extend_from_slice( + char::from_u32(encoded as u32) + .expect("mouse coordinate is a valid Unicode scalar") + .encode_utf8(&mut buffer) + .as_bytes(), + ); + } else { + bytes.push(encoded as u8); + } +} + fn terminal_command_text( snapshot: &crate::terminal::RenderSnapshot, start: (usize, usize), + end: Option<(usize, usize)>, ) -> Option { let logical_lines = crate::terminal::highlight::build_logical_lines(&snapshot.cells, snapshot.rows); @@ -792,9 +1295,17 @@ fn terminal_command_text( .byte_to_cell .iter() .position(|(row, col)| *row > start.0 || (*row == start.0 && *col >= start.1))?; + let end_byte = end + .filter(|(row, col)| *row > start.0 || (*row == start.0 && *col >= start.1)) + .and_then(|(row, col)| { + line.byte_to_cell.iter().position(|(line_row, line_col)| { + *line_row > row || (*line_row == row && *line_col >= col) + }) + }) + .unwrap_or(line.text.len()); let command = line .text - .get(start_byte..)? + .get(start_byte..end_byte)? .trim_end_matches(|character: char| character == '\0' || character.is_whitespace()) .replace('\0', ""); if !command.trim().is_empty() { @@ -804,6 +1315,14 @@ fn terminal_command_text( None } +fn command_history_text(rendered: Option<&str>, buffered: &str, input_uncertain: bool) -> String { + let buffered = buffered.trim(); + if !input_uncertain && !buffered.is_empty() { + return buffered.to_string(); + } + merge_command_text(rendered, buffered) +} + fn merge_command_text(rendered: Option<&str>, buffered: &str) -> String { let rendered = rendered.unwrap_or_default().trim(); let buffered = buffered.trim(); @@ -833,3 +1352,244 @@ fn merge_command_text(rendered: Option<&str>, buffered: &str) -> String { rendered.to_string() } } + +#[cfg(test)] +mod tests { + use alacritty_terminal::term::{ + TermMode, + cell::{Cell, Flags}, + }; + use alacritty_terminal::vte::ansi::CursorShape; + + use super::{ + alternate_screen_cursor_move, command_history_text, prompt_click_is_valid, + prompt_cursor_move, sgr_prompt_click, terminal_command_text, terminal_grid_row, + terminal_mouse_click, + }; + use crate::terminal::{CursorState, PromptClickMode, RenderCell, RenderSnapshot}; + + fn snapshot(rows: &[&str], cols: usize) -> RenderSnapshot { + let mut cells = Vec::with_capacity(rows.len() * cols); + for (row, text) in rows.iter().enumerate() { + let characters = text.chars().collect::>(); + for col in 0..cols { + let mut cell = Cell::default(); + cell.c = characters.get(col).copied().unwrap_or(' '); + cells.push(RenderCell { + row: row as i32, + col: col as i32, + cell, + }); + } + } + RenderSnapshot { + cells, + cursor: None, + selection: None, + display_offset: 0, + history_size: 0, + rows: rows.len(), + cols, + highlights: Default::default(), + } + } + + #[test] + fn accounts_for_vertical_grid_centering_when_mapping_clicks() { + assert_eq!(terminal_grid_row(10.25, 41.0, 10.0, 4), 0); + assert_eq!(terminal_grid_row(10.75, 41.0, 10.0, 4), 1); + } + + #[test] + fn limits_cursor_clicks_to_the_current_prompt_input() { + assert!(!prompt_click_is_valid((2, 8), (3, 5))); + assert!(!prompt_click_is_valid((3, 4), (3, 5))); + assert!(prompt_click_is_valid((3, 5), (3, 5))); + assert!(prompt_click_is_valid((4, 0), (3, 5))); + assert!(prompt_click_is_valid((5, 0), (3, 5))); + } + + #[test] + fn moves_across_wrapped_prompt_rows_with_horizontal_keys() { + let mut snapshot = snapshot(&["$ abcdef", "ghijk "], 8); + snapshot + .cells + .iter_mut() + .find(|cell| cell.row == 0 && cell.col == 7) + .unwrap() + .cell + .flags + .insert(Flags::WRAPLINE); + let cursor = CursorState { + row: 1, + col: 5, + shape: CursorShape::Block, + }; + + assert_eq!( + prompt_cursor_move(&snapshot, cursor, (0, 4), &[(0, 2)], false).bytes, + b"\x1b[D".repeat(9) + ); + } + + #[test] + fn counts_leading_spaces_on_a_wrapped_prompt_row() { + let mut snapshot = snapshot(&["$ abcdef", " ghijk "], 8); + snapshot + .cells + .iter_mut() + .find(|cell| cell.row == 0 && cell.col == 7) + .unwrap() + .cell + .flags + .insert(Flags::WRAPLINE); + let cursor = CursorState { + row: 1, + col: 7, + shape: CursorShape::Block, + }; + + assert_eq!( + prompt_cursor_move(&snapshot, cursor, (0, 4), &[(0, 2)], false).bytes, + b"\x1b[D".repeat(11) + ); + } + + #[test] + fn counts_spaces_between_prompt_cursor_positions() { + let snapshot = snapshot(&["$ cargo run "], 12); + let cursor = CursorState { + row: 0, + col: 11, + shape: CursorShape::Block, + }; + + assert_eq!( + prompt_cursor_move(&snapshot, cursor, (0, 4), &[(0, 2)], false).bytes, + b"\x1b[D".repeat(7) + ); + } + + #[test] + fn skips_secondary_prompts_when_moving_across_explicit_lines() { + let snapshot = snapshot(&["$ echo foo ", "> bar "], 12); + let cursor = CursorState { + row: 1, + col: 5, + shape: CursorShape::Block, + }; + + assert_eq!( + prompt_cursor_move(&snapshot, cursor, (0, 4), &[(0, 2), (1, 2)], false,).bytes, + b"\x1b[D".repeat(10) + ); + } + + #[test] + fn batches_vim_style_movement_in_iterm_order() { + let snapshot = snapshot(&["abcdefghij", "abcdefghij", "abcdefghij"], 10); + let cursor = CursorState { + row: 2, + col: 8, + shape: CursorShape::Block, + }; + + assert_eq!( + alternate_screen_cursor_move(&snapshot, cursor, (0, 3), false).bytes, + [b"\x1b[D".repeat(5), b"\x1b[A".repeat(2)].concat() + ); + + let cursor = CursorState { + row: 2, + col: 3, + shape: CursorShape::Block, + }; + assert_eq!( + alternate_screen_cursor_move(&snapshot, cursor, (0, 8), true).bytes, + [b"\x1bOA".repeat(2), b"\x1bOC".repeat(5)].concat() + ); + } + + #[test] + fn snaps_vim_style_movement_off_wide_character_spacers() { + let mut snapshot = snapshot(&["abW def"], 8); + snapshot.cells[2].cell.flags.insert(Flags::WIDE_CHAR); + snapshot.cells[3].cell.flags.insert(Flags::WIDE_CHAR_SPACER); + let cursor = CursorState { + row: 0, + col: 6, + shape: CursorShape::Block, + }; + let movement = alternate_screen_cursor_move(&snapshot, cursor, (0, 3), false); + + assert_eq!(movement.bytes, b"\x1b[D".repeat(3)); + assert_eq!(movement.target, (0, 2)); + } + + #[test] + fn corrects_column_after_crossing_a_wide_character_on_another_row() { + let mut snapshot = snapshot(&["abcdefgh", "abcdefgh", "abW defg"], 8); + snapshot.cells[18].cell.flags.insert(Flags::WIDE_CHAR); + snapshot.cells[19] + .cell + .flags + .insert(Flags::WIDE_CHAR_SPACER); + let cursor = CursorState { + row: 2, + col: 6, + shape: CursorShape::Block, + }; + + assert_eq!( + alternate_screen_cursor_move(&snapshot, cursor, (0, 3), false).bytes, + [b"\x1b[D".repeat(3), b"\x1b[A".repeat(2), b"\x1b[C".to_vec(),].concat() + ); + } + + #[test] + fn encodes_sgr_click_coordinates_for_prompt_modes() { + assert_eq!( + sgr_prompt_click((4, 7), 2, PromptClickMode::Absolute), + b"\x1b[<0;8;5M".to_vec() + ); + assert_eq!( + sgr_prompt_click((4, 7), 2, PromptClickMode::Relative), + b"\x1b[<0;8;3M".to_vec() + ); + } + + #[test] + fn encodes_plain_terminal_mouse_clicks_for_full_screen_apps() { + assert_eq!( + terminal_mouse_click((4, 7), TermMode::SGR_MOUSE), + b"\x1b[<0;8;5M\x1b[<0;8;5m".to_vec() + ); + assert_eq!( + terminal_mouse_click((2, 3), TermMode::NONE), + vec![0x1b, b'[', b'M', 32, 36, 35, 0x1b, b'[', b'M', 35, 36, 35] + ); + } + + #[test] + fn prefers_direct_input_over_stale_rendered_command_suffix() { + let command = "sh /site/vocano/vocano-restart.sh"; + let rendered = format!("{command} /sivovo-re"); + + assert_eq!( + command_history_text(Some(rendered.as_str()), command, false), + command + ); + } + + #[test] + fn truncates_rendered_command_at_the_submission_cursor() { + let command = "sh /site/vocano/vocano-restart.sh"; + let rendered = format!("$ {command} /sivovo-re"); + let snapshot = snapshot(&[rendered.as_str()], rendered.len()); + + assert_eq!( + terminal_command_text(&snapshot, (0, 2), Some((0, 2 + command.len())),), + Some(command.to_string()) + ); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index d00e7f5..2aada89 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -38,6 +38,7 @@ pub enum TabKind { } const TERMINAL_ACTIVITY_GRACE: Duration = Duration::from_millis(750); +const CLICK_CURSOR_PREDICTION_TTL: Duration = Duration::from_millis(750); const MAX_OSC_PAYLOAD_BYTES: usize = 4096; const MAX_NOTIFICATION_TEXT_BYTES: usize = 8192; const MAX_OSC99_IDENTIFIER_BYTES: usize = 128; @@ -115,10 +116,31 @@ enum OscTerminalState { enum OscTerminalEvent { Notification(TerminalNotification), ProtocolReply(Vec), + PromptStarted { + click_mode: Option, + secondary: bool, + }, + PromptEnded, CommandStarted, CommandFinished, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptClickMode { + Absolute, + Relative, + TerminalManaged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PromptInputClickState { + pub(crate) mode: PromptClickMode, + pub(crate) command_start: (usize, usize), + pub(crate) command_starts: Vec<(usize, usize)>, + pub(crate) prompt_row_offset: usize, + pub(crate) relative_click_valid: bool, +} + #[derive(Debug)] struct Osc99TextChunk { payload: String, @@ -222,10 +244,18 @@ struct OscTerminalParser { impl OscTerminalParser { /// Scans decoded terminal output without consuming it from the terminal emulator. + #[cfg(test)] fn advance(&mut self, bytes: &[u8]) -> Vec { + self.advance_with_offsets(bytes) + .into_iter() + .map(|(_, event)| event) + .collect() + } + + fn advance_with_offsets(&mut self, bytes: &[u8]) -> Vec<(usize, OscTerminalEvent)> { let mut events = Vec::new(); - for &byte in bytes { + for (index, &byte) in bytes.iter().enumerate() { match self.state { OscTerminalState::Ground => { if byte == 0x1b { @@ -261,7 +291,7 @@ impl OscTerminalParser { OscTerminalState::Payload => match byte { 0x07 | 0x9c => { if let Some(event) = self.complete_event() { - events.push(event); + events.push((index + 1, event)); } } 0x1b => self.state = OscTerminalState::PayloadEscape, @@ -270,7 +300,7 @@ impl OscTerminalParser { OscTerminalState::PayloadEscape => { if byte == b'\\' { if let Some(event) = self.complete_event() { - events.push(event); + events.push((index + 1, event)); } } else { self.push_payload_byte(0x1b); @@ -342,11 +372,7 @@ impl OscTerminalParser { }) } b"99" => self.parse_osc99(trimmed), - b"133" | b"633" => match trimmed.split(';').next() { - Some("C") => Some(OscTerminalEvent::CommandStarted), - Some("A" | "D") => Some(OscTerminalEvent::CommandFinished), - _ => None, - }, + b"133" | b"633" => parse_shell_integration_event(trimmed), b"777" => parse_osc777(trimmed).map(OscTerminalEvent::Notification), _ => None, } @@ -480,6 +506,37 @@ fn parse_osc777(payload: &str) -> Option { }) } +fn parse_shell_integration_event(payload: &str) -> Option { + let mut fields = payload.split(';'); + match fields.next()? { + "A" => { + let mut click_mode = None; + let mut secondary = false; + for field in fields { + secondary |= field == "k=s"; + click_mode = click_mode.or_else(|| parse_prompt_click_mode(field)); + } + Some(OscTerminalEvent::PromptStarted { + click_mode, + secondary, + }) + } + "B" => Some(OscTerminalEvent::PromptEnded), + "C" => Some(OscTerminalEvent::CommandStarted), + "D" => Some(OscTerminalEvent::CommandFinished), + _ => None, + } +} + +fn parse_prompt_click_mode(field: &str) -> Option { + let (key, value) = field.split_once('=')?; + match (key, value) { + ("click_events", "1") => Some(PromptClickMode::Absolute), + ("click_events", "2") => Some(PromptClickMode::Relative), + _ => None, + } +} + fn parse_osc99_metadata(metadata: &str) -> Option { let mut parsed = Osc99Metadata { identifier: None, @@ -679,8 +736,6 @@ pub enum BackendEvent { home: String, }, TransferProgress { - #[allow(dead_code)] - tab_id: String, id: String, transferred: u64, total: Option, @@ -820,6 +875,8 @@ pub struct TerminalTab { term: Term, pub cols: u16, pub rows: u16, + prompt_input: Option, + click_cursor_prediction: Option, pub backend: std::sync::Arc>, backend_events: GuardedBackendEventSender, should_cleanup_initial_blank_scrollback: bool, @@ -834,13 +891,39 @@ type HighlightCache = std::cell::RefCell< )>, >; -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CursorState { pub row: usize, pub col: usize, pub shape: CursorShape, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PromptInputState { + // Rows are stored relative to the full buffer so they remain stable when + // a multiline command scrolls the viewport. + prompt_start: (usize, usize), + command_start: Option<(usize, usize)>, + secondary_prompt_starts: Vec<(usize, usize)>, + secondary_command_starts: Vec<(usize, usize)>, + click_mode: PromptClickMode, +} + +#[derive(Debug, Clone, Copy)] +struct ClickCursorPrediction { + cursor: CursorState, + alternate_screen: bool, + expires_at: Instant, +} + +pub(crate) fn encode_cursor_key(key: u8, app_cursor_mode: bool) -> [u8; 3] { + if app_cursor_mode { + [b'\x1b', b'O', key] + } else { + [b'\x1b', b'[', key] + } +} + #[derive(Clone, PartialEq)] pub struct RenderCell { pub row: i32, @@ -1086,17 +1169,31 @@ mod backend_event_tests { #[cfg(test)] mod terminal_tab_backend_tests { - use super::{BackendTx, GuardedBackendEventSender, TerminalTab}; + use std::time::{Duration, Instant}; + + use alacritty_terminal::vte::ansi::CursorShape; + + use super::{ + BackendEvent, BackendTx, CLICK_CURSOR_PREDICTION_TTL, CursorState, + GuardedBackendEventSender, TerminalTab, + }; + + fn pending_tab() -> (TerminalTab, std::sync::mpsc::Receiver) { + let (events_tx, events_rx) = std::sync::mpsc::channel(); + ( + TerminalTab::new_local( + "tab-1".into(), + "Local".into(), + BackendTx::Pending, + GuardedBackendEventSender::new(events_tx), + ), + events_rx, + ) + } #[test] fn pending_backend_starts_only_before_a_disconnect_or_backend_swap() { - let (events_tx, _events_rx) = std::sync::mpsc::channel(); - let mut tab = TerminalTab::new_local( - "tab-1".into(), - "Local".into(), - BackendTx::Pending, - GuardedBackendEventSender::new(events_tx), - ); + let (mut tab, _events_rx) = pending_tab(); assert!(tab.backend_start_pending()); @@ -1111,13 +1208,7 @@ mod terminal_tab_backend_tests { #[test] fn clears_blank_scrollback_created_during_local_terminal_startup() { - let (events_tx, _events_rx) = std::sync::mpsc::channel(); - let mut tab = TerminalTab::new_local( - "tab-1".into(), - "Local".into(), - BackendTx::Pending, - GuardedBackendEventSender::new(events_tx), - ); + let (mut tab, _events_rx) = pending_tab(); tab.should_cleanup_initial_blank_scrollback = true; tab.resize(10, 2); @@ -1129,13 +1220,7 @@ mod terminal_tab_backend_tests { #[test] fn preserves_non_blank_scrollback_created_during_local_terminal_startup() { - let (events_tx, _events_rx) = std::sync::mpsc::channel(); - let mut tab = TerminalTab::new_local( - "tab-1".into(), - "Local".into(), - BackendTx::Pending, - GuardedBackendEventSender::new(events_tx), - ); + let (mut tab, _events_rx) = pending_tab(); tab.should_cleanup_initial_blank_scrollback = true; tab.resize(10, 2); @@ -1144,6 +1229,63 @@ mod terminal_tab_backend_tests { assert!(tab.render_snapshot(false).history_size > 0); assert!(!tab.should_cleanup_initial_blank_scrollback); } + + #[test] + fn uses_the_predicted_cursor_for_rapid_clicks_while_output_catches_up() { + let (mut tab, _events_rx) = pending_tab(); + let now = Instant::now(); + let first_target = CursorState { + row: 2, + col: 8, + shape: CursorShape::Block, + }; + tab.note_click_cursor_move_at(first_target, now); + + assert_eq!( + tab.cursor_state_for_click_at(now + Duration::from_millis(10)), + Some(first_target) + ); + + let second_target = CursorState { + row: 1, + col: 3, + shape: CursorShape::Block, + }; + tab.note_click_cursor_move_at(second_target, now + Duration::from_millis(10)); + assert_eq!( + tab.cursor_state_for_click_at(now + Duration::from_millis(20)), + Some(second_target) + ); + + tab.feed(b"\x1b[2;4H"); + assert_eq!(tab.cursor_state(), Some(second_target)); + assert_eq!( + tab.cursor_state_for_click_at(now + Duration::from_millis(30)), + Some(second_target) + ); + } + + #[test] + fn expires_or_clears_a_click_cursor_prediction_before_other_input() { + let (mut tab, _events_rx) = pending_tab(); + let actual = tab.cursor_state(); + let now = Instant::now(); + let predicted = CursorState { + row: 2, + col: 8, + shape: CursorShape::Block, + }; + tab.note_click_cursor_move_at(predicted, now); + + assert_eq!( + tab.cursor_state_for_click_at(now + CLICK_CURSOR_PREDICTION_TTL), + actual + ); + + tab.note_click_cursor_move_at(predicted, now); + tab.record_terminal_input(b"x"); + assert_eq!(tab.cursor_state_for_click_at(now), actual); + } } #[cfg(test)] @@ -1156,8 +1298,8 @@ mod osc_terminal_tests { use super::{ BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender, MAX_OSC_PAYLOAD_BYTES, MAX_PENDING_OSC99_NOTIFICATIONS, OSC99_PENDING_TTL, OscTerminalEvent, OscTerminalParser, - TerminalNotification, TerminalNotificationOccasion, TerminalNotificationSource, - TerminalTab, + PromptClickMode, PromptInputClickState, TerminalNotification, TerminalNotificationOccasion, + TerminalNotificationSource, TerminalTab, }; fn notification( @@ -1379,7 +1521,11 @@ mod osc_terminal_tests { assert_eq!( parser.advance(b"\x1b]133;A\x07\x1b]133;B\x07\x1b]133;C\x07"), vec![ - OscTerminalEvent::CommandFinished, + OscTerminalEvent::PromptStarted { + click_mode: None, + secondary: false, + }, + OscTerminalEvent::PromptEnded, OscTerminalEvent::CommandStarted, ] ); @@ -1396,6 +1542,118 @@ mod osc_terminal_tests { ); } + #[test] + fn parses_prompt_click_modes_and_preserves_event_offsets() { + let mut parser = OscTerminalParser::default(); + let bytes = b"prefix\x1b]133;A;click_events=2\x07suffix"; + + let (event_end, event) = parser + .advance_with_offsets(bytes) + .into_iter() + .next() + .expect("prompt marker event"); + assert_eq!(event_end, b"prefix\x1b]133;A;click_events=2\x07".len()); + assert_eq!( + event, + OscTerminalEvent::PromptStarted { + click_mode: Some(PromptClickMode::Relative), + secondary: false, + } + ); + assert_eq!( + parser.advance(b"\x1b]133;A;cl=m\x07"), + vec![OscTerminalEvent::PromptStarted { + click_mode: None, + secondary: false, + }] + ); + } + + #[test] + fn tracks_prompt_input_start_after_prompt_end_marker() { + let (events_tx, _events_rx) = mpsc::channel(); + let mut tab = TerminalTab::new_local( + "tab-1".into(), + "Local".into(), + BackendTx::Pending, + GuardedBackendEventSender::new(events_tx), + ); + + tab.feed(b"\x1b]133;A;click_events=2\x07$ \x1b]133;B\x07"); + + assert_eq!( + tab.prompt_input_click_state((0, 2)), + Some(PromptInputClickState { + mode: PromptClickMode::Relative, + command_start: (0, 2), + command_starts: vec![(0, 2)], + prompt_row_offset: 0, + relative_click_valid: true, + }) + ); + } + + #[test] + fn tracks_command_starts_for_secondary_prompts() { + let (events_tx, _events_rx) = mpsc::channel(); + let mut tab = TerminalTab::new_local( + "tab-1".into(), + "Local".into(), + BackendTx::Pending, + GuardedBackendEventSender::new(events_tx), + ); + + tab.feed( + b"\x1b]133;A;click_events=2\x07$ \x1b]133;B\x07echo\r\n\x1b]133;A;k=s\x07> \x1b]133;B\x07more", + ); + + assert_eq!( + tab.prompt_input_click_state((0, 2)), + Some(PromptInputClickState { + mode: PromptClickMode::Relative, + command_start: (0, 2), + command_starts: vec![(0, 2), (1, 2)], + prompt_row_offset: 0, + relative_click_valid: false, + }) + ); + assert_eq!( + tab.prompt_input_click_state((1, 2)), + Some(PromptInputClickState { + mode: PromptClickMode::Relative, + command_start: (1, 2), + command_starts: vec![(0, 2), (1, 2)], + prompt_row_offset: 0, + relative_click_valid: true, + }) + ); + } + + #[test] + fn keeps_prompt_coordinates_stable_when_multiline_input_scrolls() { + let (events_tx, _events_rx) = mpsc::channel(); + let mut tab = TerminalTab::new_local( + "tab-1".into(), + "Local".into(), + BackendTx::Pending, + GuardedBackendEventSender::new(events_tx), + ); + tab.resize(8, 2); + + tab.feed(b"\x1b]133;A;click_events=2\x07$ \x1b]133;B\x071234567890123456"); + + assert_eq!( + tab.prompt_input_click_state((0, 0)), + Some(PromptInputClickState { + mode: PromptClickMode::Relative, + command_start: (0, 0), + command_starts: vec![(0, 0)], + prompt_row_offset: 1, + relative_click_valid: true, + }) + ); + } + #[test] fn ignores_other_osc_commands_and_windows_terminal_namespaces() { let mut parser = OscTerminalParser::default(); @@ -1555,6 +1813,8 @@ impl TerminalTab { term: new_term(100, 30, shared_backend.clone(), id, events.clone()), cols: 100, rows: 30, + prompt_input: None, + click_cursor_prediction: None, backend: shared_backend, backend_events, should_cleanup_initial_blank_scrollback: cfg!(windows) && kind == TabKind::Local, @@ -1569,30 +1829,96 @@ impl TerminalTab { self.output_activity_until = Some(Instant::now() + TERMINAL_ACTIVITY_GRACE); } let mut notifications = Vec::new(); - for event in self.osc_terminal_parser.advance(&decoded) { - match event { - OscTerminalEvent::Notification(notification) => { - self.command_running = false; - self.output_activity_until = None; - notifications.push(notification); - } - OscTerminalEvent::ProtocolReply(reply) => { - self.send_backend(BackendCommand::Input(reply)); - } - OscTerminalEvent::CommandStarted => { - self.shell_integration_available = true; - self.command_running = true; - } - OscTerminalEvent::CommandFinished => { - self.shell_integration_available = true; - self.command_running = false; - self.output_activity_until = None; + let mut processed_until = 0; + for (event_end, event) in self.osc_terminal_parser.advance_with_offsets(&decoded) { + self.processor + .advance(&mut self.term, &decoded[processed_until..event_end]); + self.handle_osc_terminal_event(event, &mut notifications); + processed_until = event_end; + } + self.processor + .advance(&mut self.term, &decoded[processed_until..]); + self.cleanup_initial_blank_scrollback(); + self.reconcile_click_cursor_prediction(Instant::now()); + notifications + } + + fn handle_osc_terminal_event( + &mut self, + event: OscTerminalEvent, + notifications: &mut Vec, + ) { + match event { + OscTerminalEvent::Notification(notification) => { + self.command_running = false; + self.output_activity_until = None; + notifications.push(notification); + } + OscTerminalEvent::ProtocolReply(reply) => { + self.send_backend(BackendCommand::Input(reply)); + } + OscTerminalEvent::PromptStarted { + click_mode, + secondary, + } => { + self.clear_click_cursor_prediction(); + self.shell_integration_available = true; + self.command_running = false; + self.output_activity_until = None; + let prompt_start = self.buffer_cursor_position().unwrap_or((0, 0)); + if secondary { + if let Some(prompt_input) = self.prompt_input.as_mut() { + prompt_input.secondary_prompt_starts.push(prompt_start); + if let Some(click_mode) = click_mode { + prompt_input.click_mode = click_mode; + } + } else { + self.prompt_input = Some(PromptInputState { + prompt_start, + command_start: None, + secondary_prompt_starts: Vec::new(), + secondary_command_starts: Vec::new(), + click_mode: click_mode.unwrap_or(PromptClickMode::TerminalManaged), + }); + } + } else { + self.prompt_input = Some(PromptInputState { + prompt_start, + command_start: None, + secondary_prompt_starts: Vec::new(), + secondary_command_starts: Vec::new(), + click_mode: click_mode.unwrap_or(PromptClickMode::TerminalManaged), + }); } } + OscTerminalEvent::PromptEnded => { + let command_start = self.buffer_cursor_position(); + if let Some(prompt_input) = self.prompt_input.as_mut() { + if prompt_input.secondary_prompt_starts.len() + > prompt_input.secondary_command_starts.len() + { + if let Some(command_start) = command_start { + prompt_input.secondary_command_starts.push(command_start); + } + } else if prompt_input.command_start.is_none() { + prompt_input.command_start = command_start; + } + } + } + OscTerminalEvent::CommandStarted => { + self.clear_click_cursor_prediction(); + self.shell_integration_available = true; + self.command_running = true; + self.prompt_input = None; + } + OscTerminalEvent::CommandFinished => { + self.clear_click_cursor_prediction(); + self.shell_integration_available = true; + self.command_running = false; + self.output_activity_until = None; + self.prompt_input = None; + } } - self.processor.advance(&mut self.term, &decoded); - self.cleanup_initial_blank_scrollback(); - notifications } /// Drops ConPTY startup artifacts without removing real terminal output. @@ -1636,6 +1962,7 @@ impl TerminalTab { } pub(crate) fn record_terminal_input(&mut self, bytes: &[u8]) { + self.clear_click_cursor_prediction(); if self.shell_integration_available && !self.is_alternate_screen_active() && bytes.iter().any(|byte| matches!(byte, b'\r' | b'\n')) @@ -1650,6 +1977,8 @@ impl TerminalTab { self.command_running = false; self.output_activity_until = None; self.shell_integration_available = false; + self.prompt_input = None; + self.clear_click_cursor_prediction(); self.osc_terminal_parser = OscTerminalParser::default(); changed } @@ -1677,6 +2006,8 @@ impl TerminalTab { self.osc_terminal_parser = OscTerminalParser::default(); self.output_activity_until = None; self.command_running = false; + self.prompt_input = None; + self.clear_click_cursor_prediction(); if let Some(session) = self.session.as_mut() { session.terminal_encoding = encoding; } @@ -1731,6 +2062,7 @@ impl TerminalTab { if self.cols != new_cols || self.rows != new_rows { self.cols = new_cols; self.rows = new_rows; + self.clear_click_cursor_prediction(); tracing::info!( "[ui] terminal resized to {}x{} (cols x rows)", self.cols, @@ -1766,14 +2098,121 @@ impl TerminalTab { }) } + pub(crate) fn cursor_state_for_click(&mut self) -> Option { + self.cursor_state_for_click_at(Instant::now()) + } + + fn cursor_state_for_click_at(&mut self, now: Instant) -> Option { + let actual = self.cursor_state(); + let Some(prediction) = self.click_cursor_prediction else { + return actual; + }; + if now >= prediction.expires_at + || prediction.alternate_screen != self.is_alternate_screen_active() + { + self.click_cursor_prediction = None; + return actual; + } + Some(prediction.cursor) + } + + pub(crate) fn note_click_cursor_move(&mut self, cursor: CursorState) { + self.note_click_cursor_move_at(cursor, Instant::now()); + } + + fn note_click_cursor_move_at(&mut self, cursor: CursorState, now: Instant) { + self.click_cursor_prediction = Some(ClickCursorPrediction { + cursor, + alternate_screen: self.is_alternate_screen_active(), + expires_at: now + CLICK_CURSOR_PREDICTION_TTL, + }); + } + + pub(crate) fn clear_click_cursor_prediction(&mut self) { + self.click_cursor_prediction = None; + } + + fn reconcile_click_cursor_prediction(&mut self, now: Instant) { + let _ = self.cursor_state_for_click_at(now); + } + + fn buffer_cursor_position(&self) -> Option<(usize, usize)> { + let grid = self.term.grid(); + let row = usize::try_from(grid.cursor.point.line.0).ok()?; + Some(( + grid.history_size().saturating_add(row), + grid.cursor.point.column.0, + )) + } + pub fn app_cursor_mode(&self) -> bool { self.term.mode().contains(TermMode::APP_CURSOR) } + pub(crate) fn mouse_tracking_enabled(&self) -> bool { + self.term.mode().intersects( + TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG, + ) + } + pub fn is_alternate_screen_active(&self) -> bool { self.term.mode().contains(TermMode::ALT_SCREEN) } + pub(crate) fn prompt_input_click_state( + &self, + target: (usize, usize), + ) -> Option { + let prompt = self.prompt_input.as_ref()?; + let history_size = self.term.grid().history_size(); + let target_buffer = (history_size.saturating_add(target.0), target.1); + let cursor_buffer = self.buffer_cursor_position()?; + let current_prompt_start = prompt + .secondary_prompt_starts + .iter() + .rev() + .find(|start| **start <= cursor_buffer) + .copied() + .unwrap_or(prompt.prompt_start); + let primary_command_start = prompt.command_start?; + let mut command_starts = + Vec::with_capacity(1usize.saturating_add(prompt.secondary_command_starts.len())); + command_starts.push(primary_command_start); + command_starts.extend(prompt.secondary_command_starts.iter().copied()); + let target_command_start = command_starts + .iter() + .rev() + .find(|start| **start <= target_buffer) + .copied() + .unwrap_or(primary_command_start); + let viewport_point = |point: (usize, usize)| { + if point.0 < history_size { + (0, 0) + } else { + ( + point + .0 + .saturating_sub(history_size) + .min(self.rows.saturating_sub(1) as usize), + point.1, + ) + } + }; + let target_command_start = viewport_point(target_command_start); + let mut command_starts = command_starts + .into_iter() + .map(viewport_point) + .collect::>(); + command_starts.dedup(); + Some(PromptInputClickState { + mode: prompt.click_mode, + command_start: target_command_start, + command_starts, + prompt_row_offset: target_buffer.0.saturating_sub(current_prompt_start.0), + relative_click_valid: target_buffer >= current_prompt_start, + }) + } + pub fn render_snapshot(&self, keyword_highlight: bool) -> RenderSnapshot { let rows = self.rows; let cols = self.cols; @@ -1869,33 +2308,30 @@ impl TerminalTab { pub fn scroll_history(&mut self, delta: i32) { if delta != 0 { + self.clear_click_cursor_prediction(); self.term.scroll_display(Scroll::Delta(delta)); } } pub fn scroll_up_by(&mut self, lines: usize) { if lines != 0 { + self.clear_click_cursor_prediction(); self.term.scroll_display(Scroll::Delta(lines as i32)); } } pub fn scroll_down_by(&mut self, lines: usize) { if lines != 0 { + self.clear_click_cursor_prediction(); self.term.scroll_display(Scroll::Delta(-(lines as i32))); } } pub fn scroll_to_bottom(&mut self) { + self.clear_click_cursor_prediction(); self.term.scroll_display(Scroll::Bottom); } - #[allow(dead_code)] - pub fn has_selection(&self) -> bool { - self.term - .selection_to_string() - .is_some_and(|text| !text.is_empty()) - } - pub fn clear_selection(&mut self) { self.term.selection = None; } @@ -1931,6 +2367,7 @@ impl TerminalTab { } pub fn paste_text(&mut self, text: &str) { + self.clear_click_cursor_prediction(); let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE); let paste_text = text .replace('\x1b', "")