fix(terminal): 修复 SSH 历史命令残留与光标定位 (#114)

This commit is contained in:
Realm
2026-08-27 00:09:40 +08:00
committed by Tom Cruise
parent 6ccb9ff601
commit 3ef6e8cfb8
14 changed files with 1956 additions and 481 deletions
+2 -1
View File
@@ -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"
-7
View File
@@ -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";
-2
View File
@@ -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;
+164 -131
View File
@@ -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<String>,
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<String, String>,
pub(crate) ssh_command_starts: HashMap<String, (usize, usize)>,
pub(crate) ssh_command_input_uncertain: HashSet<String>,
pub(crate) system_status: Option<SharedString>,
pub(crate) server_monitor_view: ServerMonitorView,
pub(crate) remote_processes: Vec<RemoteProcess>,
@@ -358,7 +384,7 @@ pub(crate) struct Ashell {
pub(crate) terminal_panel_bounds: Option<Bounds<Pixels>>,
pub(crate) terminal_bounds: HashMap<String, Bounds<Pixels>>,
pub(crate) terminal_selecting: bool,
pub(crate) dragging_splitter: Option<(Vec<usize>, usize)>, // (parent_path, child_index)
pub(crate) dragging_splitter: Option<Vec<usize>>,
pub(crate) drag_split_origin: Option<gpui::Point<Pixels>>,
pub(crate) terminal_marked_text: Option<String>,
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::<HashSet<_>>()
})
.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::<HashSet<_>>();
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<f32>, 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<Self>) {
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() {
+55 -87
View File
@@ -61,6 +61,20 @@ impl Ashell {
});
}
fn search_tab_index(&self) -> Option<usize> {
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<Self>) {
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);
}
}
+4 -16
View File
@@ -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(),
+375 -27
View File
@@ -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<Result<ShellIntegrationPaths, String>> = OnceLock::new();
fn file_shell_integration(executable: &Path, login_bash: bool) -> Option<FileShellIntegration> {
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<ShellIntegrationPaths> {
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<bool> {
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<std::path::PathBuf> {
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"/}""#
));
}
}
+22
View File
@@ -183,6 +183,28 @@ pub(crate) fn clear_unread_indicator(window_handle: Option<isize>) {
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::{
-1
View File
@@ -1216,7 +1216,6 @@ impl ConfigStore {
self.cache.workspace_panels.as_ref()
}
#[allow(dead_code)]
pub fn body_panels(&self) -> Option<&Vec<f32>> {
self.cache.body_panels.as_ref()
}
+27 -65
View File
@@ -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<usize>,
child_index: usize,
event: &MouseDownEvent,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
self.dragging_splitter = Some((parent_path, child_index));
pub(crate) fn start_drag_split(&mut self, parent_path: Vec<usize>, 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<Self>,
) {
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);
}
}
}
+9 -44
View File
@@ -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<AtomicU8>);
struct TransferContext<'a> {
flag: &'a TransferStateFlag,
events: &'a std::sync::mpsc::Sender<BackendEvent>,
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<crate::terminal::BackendEvent>,
tab_id: &str,
id: &str,
transferred: u64,
total: Option<u64>,
@@ -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<SftpCommand>,
#[allow(dead_code)]
join: Option<JoinHandle<()>>,
}
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<BackendEvent>,
tab_id: &str,
id: &str,
) -> Result<String> {
// 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,
+28 -27
View File
@@ -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<SystemSnapshot> {
let mut kv = BTreeMap::new();
let mut disks = Vec::new();
@@ -224,24 +232,16 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
// (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);
}
+769 -9
View File
@@ -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<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<u8>, 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<usize> {
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<u8>,
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<u8>,
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<Option<usize>> = vec![None; snapshot.rows];
let mut row_text_ends: Vec<Option<usize>> = 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<u8> {
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<u8> {
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<u8>, 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<String> {
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::<Vec<_>>();
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())
);
}
}
+501 -64
View File
@@ -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<u8>),
PromptStarted {
click_mode: Option<PromptClickMode>,
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<OscTerminalEvent> {
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<TerminalNotification> {
})
}
fn parse_shell_integration_event(payload: &str) -> Option<OscTerminalEvent> {
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<PromptClickMode> {
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<Osc99Metadata> {
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<u64>,
@@ -820,6 +875,8 @@ pub struct TerminalTab {
term: Term<TerminalListener>,
pub cols: u16,
pub rows: u16,
prompt_input: Option<PromptInputState>,
click_cursor_prediction: Option<ClickCursorPrediction>,
pub backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
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<BackendEvent>) {
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<TerminalNotification>,
) {
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<CursorState> {
self.cursor_state_for_click_at(Instant::now())
}
fn cursor_state_for_click_at(&mut self, now: Instant) -> Option<CursorState> {
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<PromptInputClickState> {
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::<Vec<_>>();
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', "")