mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 00:00:59 +00:00
fix(terminal): 修复 SSH 历史命令残留与光标定位 (#114)
This commit is contained in:
+2
-1
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"/}""#
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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', "")
|
||||
|
||||
Reference in New Issue
Block a user