mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-04 00:00:26 +00:00
2374 lines
94 KiB
Rust
2374 lines
94 KiB
Rust
pub mod config_sync;
|
|
pub mod constants;
|
|
pub mod controls;
|
|
pub mod dialogs;
|
|
pub mod keybinding_recorder;
|
|
pub mod resizable;
|
|
pub mod search;
|
|
pub mod startup;
|
|
pub mod system_menu;
|
|
pub mod theme;
|
|
pub mod ui;
|
|
pub mod window_drag;
|
|
|
|
use std::{
|
|
cell::{Cell, RefCell},
|
|
collections::{HashMap, HashSet},
|
|
ops::Range,
|
|
rc::Rc,
|
|
sync::mpsc,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use crate::app::resizable::ResizableState;
|
|
use gpui::{
|
|
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, SharedString, Size,
|
|
UniformListScrollHandle, Window, point, px, size,
|
|
};
|
|
use gpui_component::{
|
|
Theme, ThemeMode, ThemeRegistry,
|
|
input::{InputEvent, InputState},
|
|
scroll::ScrollbarHandle,
|
|
};
|
|
use rust_i18n::t;
|
|
use tokio::runtime::Runtime;
|
|
|
|
use crate::{
|
|
session::config::{AuthMethod, ConfigStore},
|
|
session::ssh_config::SshConfigEntry,
|
|
system::{RemotePort, RemoteProcess, SystemSampler, SystemSnapshot},
|
|
terminal::{
|
|
self, BackendEvent, TabKind, TerminalNotification, TerminalNotificationOccasion,
|
|
TerminalTab,
|
|
},
|
|
text_encoding::TextEncoding,
|
|
};
|
|
|
|
const SYSTEM_HISTORY_LIMIT: usize = 20;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) enum PaneLayout {
|
|
Single(String),
|
|
Horizontal(Vec<PaneLayout>, f32), // children, split_ratio (0.0-1.0)
|
|
Vertical(Vec<PaneLayout>, f32), // children, split_ratio (0.0-1.0)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct TabGroup {
|
|
pub(crate) id: String,
|
|
pub(crate) title: String,
|
|
pub(crate) pane_root: PaneLayout,
|
|
pub(crate) sftp: Option<crate::terminal::SftpUiState>,
|
|
pub(crate) sftp_tab_id: Option<String>,
|
|
}
|
|
|
|
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) => tab_ids.push(id),
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn contains(&self, tab_id: &str) -> bool {
|
|
match self {
|
|
PaneLayout::Single(id) => id == tab_id,
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
children.iter().any(|c| c.contains(tab_id))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn focused_tab_id(&self, path: &[usize]) -> Option<&str> {
|
|
match self {
|
|
PaneLayout::Single(id) if path.is_empty() => Some(id.as_str()),
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
let (&first, rest) = path.split_first()?;
|
|
children.get(first).and_then(|c| c.focused_tab_id(rest))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn replace_at(&mut self, path: &[usize], replacement: PaneLayout) {
|
|
match (self, path) {
|
|
(this @ PaneLayout::Single(_), []) => *this = replacement,
|
|
(
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _),
|
|
[first, rest @ ..],
|
|
) => {
|
|
if let Some(child) = children.get_mut(*first) {
|
|
child.replace_at(rest, replacement);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
pub fn remove_tab(&mut self, tab_id: &str) {
|
|
match self {
|
|
PaneLayout::Single(id) if id == tab_id => {
|
|
*self = PaneLayout::Single(String::new());
|
|
}
|
|
PaneLayout::Single(_) => {}
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
for child in children.iter_mut() {
|
|
child.remove_tab(tab_id);
|
|
}
|
|
children.retain(|c| !matches!(c, PaneLayout::Single(id) if id.is_empty()));
|
|
if children.is_empty() {
|
|
*self = PaneLayout::Single(String::new());
|
|
} else if children.len() == 1 {
|
|
if let Some(replacement) = children.pop() {
|
|
*self = replacement;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn total_panes(&self) -> usize {
|
|
match self {
|
|
PaneLayout::Single(_) => 1,
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
children.iter().map(|c| c.total_panes()).sum()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn should_show_terminal_notification(
|
|
occasion: TerminalNotificationOccasion,
|
|
window_active: bool,
|
|
terminal_visible: bool,
|
|
) -> bool {
|
|
if window_active && terminal_visible {
|
|
return false;
|
|
}
|
|
|
|
match occasion {
|
|
TerminalNotificationOccasion::Always => true,
|
|
TerminalNotificationOccasion::Unfocused => !window_active,
|
|
TerminalNotificationOccasion::Invisible => !terminal_visible,
|
|
}
|
|
}
|
|
|
|
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,
|
|
viewport_lines: usize,
|
|
display_offset: usize,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct TerminalScrollbarHandle {
|
|
state: Rc<RefCell<Option<TerminalScrollbarState>>>,
|
|
pub(crate) future_display_offset: Rc<Cell<Option<usize>>>,
|
|
}
|
|
|
|
impl TerminalScrollbarHandle {
|
|
pub(crate) fn update(&self, snapshot: &terminal::RenderSnapshot, line_height: Pixels) {
|
|
self.state.replace(Some(TerminalScrollbarState {
|
|
line_height,
|
|
total_lines: snapshot.history_size + snapshot.rows,
|
|
viewport_lines: snapshot.rows,
|
|
display_offset: snapshot.display_offset,
|
|
}));
|
|
}
|
|
}
|
|
|
|
impl ScrollbarHandle for TerminalScrollbarHandle {
|
|
fn offset(&self) -> Point<Pixels> {
|
|
let state_ref = self.state.borrow();
|
|
let Some(state) = state_ref.as_ref() else {
|
|
return point(px(0.), px(0.));
|
|
};
|
|
let scroll_offset = state
|
|
.total_lines
|
|
.saturating_sub(state.viewport_lines)
|
|
.saturating_sub(state.display_offset);
|
|
point(px(0.), -(scroll_offset as f32 * state.line_height))
|
|
}
|
|
|
|
fn set_offset(&self, offset: Point<Pixels>) {
|
|
let state_ref = self.state.borrow();
|
|
let Some(state) = state_ref.as_ref() else {
|
|
return;
|
|
};
|
|
let offset_delta = (offset.y / state.line_height).round() as i32;
|
|
let max_offset = state.total_lines.saturating_sub(state.viewport_lines);
|
|
let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32);
|
|
self.future_display_offset
|
|
.set(Some(display_offset as usize));
|
|
}
|
|
|
|
fn content_size(&self) -> Size<Pixels> {
|
|
let state_ref = self.state.borrow();
|
|
let Some(state) = state_ref.as_ref() else {
|
|
return size(px(0.), px(0.));
|
|
};
|
|
size(
|
|
px(0.),
|
|
state.total_lines.max(state.viewport_lines) as f32 * state.line_height,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum DialogKind {
|
|
About,
|
|
Settings,
|
|
SessionSelector,
|
|
ConnectionExport,
|
|
Transfers,
|
|
NewSsh,
|
|
ConnectionGroup,
|
|
SftpRename,
|
|
SftpEditor,
|
|
Processes,
|
|
Ports,
|
|
SshReconnect,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub(crate) enum ServerMonitorView {
|
|
#[default]
|
|
Cpu,
|
|
Memory,
|
|
}
|
|
|
|
pub(crate) struct Ashell {
|
|
pub(crate) focus_handle: FocusHandle,
|
|
pub(crate) selector_focus_handle: FocusHandle,
|
|
pub(crate) host_input: Entity<InputState>,
|
|
pub(crate) session_name_input: Entity<InputState>,
|
|
pub(crate) session_group: String,
|
|
pub(crate) port_input: Entity<InputState>,
|
|
pub(crate) user_input: Entity<InputState>,
|
|
pub(crate) password_input: Entity<InputState>,
|
|
pub(crate) key_path_input: Entity<InputState>,
|
|
pub(crate) key_inline_input: Entity<InputState>,
|
|
pub(crate) passphrase_input: Entity<InputState>,
|
|
pub(crate) baud_rate_input: Entity<InputState>,
|
|
pub(crate) session_protocol: String,
|
|
pub(crate) ssh_terminal_encoding: TextEncoding,
|
|
pub(crate) ssh_proxy_type: String,
|
|
pub(crate) proxy_host_input: Entity<InputState>,
|
|
pub(crate) proxy_port_input: Entity<InputState>,
|
|
pub(crate) proxy_user_input: Entity<InputState>,
|
|
pub(crate) proxy_password_input: Entity<InputState>,
|
|
pub(crate) global_proxy_type: String,
|
|
pub(crate) global_proxy_host_input: Entity<InputState>,
|
|
pub(crate) global_proxy_port_input: Entity<InputState>,
|
|
pub(crate) global_proxy_user_input: Entity<InputState>,
|
|
pub(crate) global_proxy_password_input: Entity<InputState>,
|
|
pub(crate) sync_endpoint_input: Entity<InputState>,
|
|
pub(crate) sync_username_input: Entity<InputState>,
|
|
pub(crate) sync_webdav_password_input: Entity<InputState>,
|
|
pub(crate) sync_s3_endpoint_input: Entity<InputState>,
|
|
pub(crate) sync_s3_region_input: Entity<InputState>,
|
|
pub(crate) sync_s3_bucket_input: Entity<InputState>,
|
|
pub(crate) sync_s3_object_key_input: Entity<InputState>,
|
|
pub(crate) sync_s3_access_key_input: Entity<InputState>,
|
|
pub(crate) sync_s3_secret_key_input: Entity<InputState>,
|
|
pub(crate) sync_s3_session_token_input: Entity<InputState>,
|
|
pub(crate) sync_encryption_password_input: Entity<InputState>,
|
|
pub(crate) sync_in_progress: bool,
|
|
pub(crate) sync_status: SharedString,
|
|
pub(crate) sftp_path_input: Entity<InputState>,
|
|
pub(crate) remote_process_filter_input: Entity<InputState>,
|
|
pub(crate) remote_port_filter_input: Entity<InputState>,
|
|
pub(crate) connection_filter_input: Entity<InputState>,
|
|
pub(crate) connection_group_name_input: Entity<InputState>,
|
|
pub(crate) command_history_filter_input: Entity<InputState>,
|
|
pub(crate) connection_management_mode: bool,
|
|
pub(crate) selected_connection_ids: HashSet<String>,
|
|
pub(crate) selected_command_history: HashSet<(String, usize)>,
|
|
pub(crate) ssh_auth_method: AuthMethod,
|
|
pub(crate) ssh_config_entries: Vec<SshConfigEntry>,
|
|
pub(crate) ssh_config_selected: Option<usize>,
|
|
pub(crate) editing_session_id: Option<String>,
|
|
pub(crate) editing_connection_group: Option<String>,
|
|
pub(crate) follow_system_theme: bool,
|
|
pub(crate) theme_mode: ThemeMode,
|
|
pub(crate) light_theme_name: SharedString,
|
|
pub(crate) dark_theme_name: SharedString,
|
|
pub(crate) ui_font_size: f32,
|
|
pub(crate) terminal_font_size: f32,
|
|
pub(crate) terminal_zoom_accumulator: f32,
|
|
pub(crate) ui_font_family: SharedString,
|
|
pub(crate) terminal_font_family: SharedString,
|
|
pub(crate) tabs: Vec<TerminalTab>,
|
|
pub(crate) active_tab: Option<String>,
|
|
pub(crate) tab_groups: Vec<TabGroup>,
|
|
pub(crate) active_group: Option<String>,
|
|
pub(crate) selector_selection: usize,
|
|
pub(crate) workspace_panels: Entity<ResizableState>,
|
|
pub(crate) body_panels: Entity<ResizableState>,
|
|
pub(crate) sftp_tree_panels: Entity<ResizableState>,
|
|
pub(crate) sftp_file_columns: Entity<ResizableState>,
|
|
pub(crate) is_layout_reset: bool,
|
|
pub(crate) terminal_scrollbars: HashMap<String, TerminalScrollbarHandle>,
|
|
pub(crate) remote_files_scroll_handle: UniformListScrollHandle,
|
|
pub(crate) remote_files_horizontal_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) remote_files_columns_viewport_width: Option<Pixels>,
|
|
pub(crate) remote_tree_scroll_handle: UniformListScrollHandle,
|
|
pub(crate) disk_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) process_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) port_scroll_handle: UniformListScrollHandle,
|
|
pub(crate) tabs_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) tabs_viewport_bounds: Option<Bounds<Pixels>>,
|
|
pub(crate) tab_scroll_animation_id: u64,
|
|
pub(crate) selector_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) saved_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) command_history_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) connection_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) saved_sessions_overflowing: bool,
|
|
pub(crate) connection_progress: Option<ConnectionProgress>,
|
|
pub(crate) pending_sftp_path_sync: Option<String>,
|
|
pub(crate) sftp_context_menu: Option<SftpContextMenuState>,
|
|
pub(crate) sftp_rename_input: Entity<InputState>,
|
|
pub(crate) sftp_rename_state: Option<SftpRenameState>,
|
|
pub(crate) sftp_editor_input: Entity<InputState>,
|
|
pub(crate) sftp_editor_state: Option<SftpEditorState>,
|
|
pub(crate) sftp_creating_folder: bool,
|
|
pub(crate) sftp_new_folder_input: Entity<InputState>,
|
|
pub(crate) sftp_delete_scroll_handle: gpui::ScrollHandle,
|
|
pub(crate) show_hidden_files: bool,
|
|
pub(crate) transfers: Vec<crate::terminal::Transfer>,
|
|
pub(crate) show_transfers_dialog: bool,
|
|
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>,
|
|
pub(crate) remote_process_status: Option<SharedString>,
|
|
pub(crate) remote_processes_in_flight: bool,
|
|
pub(crate) expanded_process_pid: Option<u32>,
|
|
pub(crate) remote_ports: Vec<RemotePort>,
|
|
pub(crate) remote_ports_status: Option<SharedString>,
|
|
pub(crate) remote_ports_in_flight: bool,
|
|
pub(crate) terminating_processes: HashSet<u32>,
|
|
pub(crate) pane_root: PaneLayout,
|
|
pub(crate) focused_pane_path: Vec<usize>,
|
|
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>>,
|
|
pub(crate) drag_split_origin: Option<gpui::Point<Pixels>>,
|
|
pub(crate) terminal_marked_text: Option<String>,
|
|
pub(crate) sftp_panel_minimized: bool,
|
|
pub(crate) sidebar_collapsed: bool,
|
|
pub(crate) window_active: bool,
|
|
pub(crate) should_move_window: bool,
|
|
pub(crate) native_window_handle: Option<isize>,
|
|
pub(crate) unread_terminal_notifications: HashSet<String>,
|
|
pub(crate) prev_monitoring_size: Option<Pixels>,
|
|
pub(crate) status: SharedString,
|
|
pub(crate) config: ConfigStore,
|
|
pub(crate) active_title_bar_style: crate::session::config::TitleBarStyle,
|
|
pub(crate) cursor_style: crate::session::config::CursorStyle,
|
|
pub(crate) system_sampler: SystemSampler,
|
|
pub(crate) recording_action: Option<String>,
|
|
pub(crate) active_dialog: Option<DialogKind>,
|
|
/// Error message when a recorded keybinding conflicts with another
|
|
pub(crate) keybind_error: Option<(String, String)>, // (action_id, error_message)
|
|
/// Whether workspace keybindings are currently suspended (during settings)
|
|
pub(crate) keybinds_suspended: bool,
|
|
pub(crate) system: SystemSnapshot,
|
|
pub(crate) cpu_history: Vec<f32>,
|
|
pub(crate) net_rx_history: Vec<f32>,
|
|
pub(crate) net_tx_history: Vec<f32>,
|
|
pub(crate) last_system_sample: Instant,
|
|
pub(crate) last_theme_sync: Instant,
|
|
|
|
pub(crate) search_input: Entity<InputState>,
|
|
pub(crate) search_active: bool,
|
|
pub(crate) search_query: String,
|
|
pub(crate) search_matches: Vec<(i32, i32)>,
|
|
pub(crate) search_current: usize,
|
|
pub(crate) search_target_tab: Option<String>,
|
|
pub(crate) search_bar_bounds: Option<Bounds<Pixels>>,
|
|
|
|
pub(crate) system_tab_id: Option<String>,
|
|
pub(crate) last_applied_system_tab_id: Option<String>,
|
|
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
|
|
|
|
pub(crate) remote_sample_in_flight: bool,
|
|
pub(crate) runtime: Runtime,
|
|
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
|
|
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
|
|
pub(crate) last_window_size: Option<gpui::Size<Pixels>>,
|
|
pub(crate) last_sidebar_width: Option<Pixels>,
|
|
pub(crate) pending_local_terminal_resizes: HashMap<String, (u16, u16)>,
|
|
pub(crate) local_terminal_resize_task: Option<gpui::Task<()>>,
|
|
pub(crate) hovered_url: Option<HoveredUrl>,
|
|
pub(crate) cmd_ctrl_pressed: bool,
|
|
pub(crate) _subscriptions: Vec<gpui::Subscription>,
|
|
pub(crate) last_window_bounds: Option<gpui::WindowBounds>,
|
|
pub(crate) save_lock: std::sync::Arc<std::sync::Mutex<()>>,
|
|
pub(crate) save_latest_seq: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) struct HoveredUrl {
|
|
pub(crate) url: String,
|
|
pub(crate) tab_id: String,
|
|
pub(crate) cells: Vec<(usize, usize)>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) enum SelectorEntry {
|
|
Local,
|
|
Saved(String),
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ConnectionProgress {
|
|
pub(crate) tab_id: String,
|
|
pub(crate) title: SharedString,
|
|
pub(crate) lines: Vec<SharedString>,
|
|
pub(crate) failed: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct SftpContextMenuState {
|
|
pub(crate) remote_path: String,
|
|
pub(crate) is_dir: bool,
|
|
pub(crate) position: Point<Pixels>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct SftpRenameState {
|
|
pub(crate) group_id: String,
|
|
pub(crate) old_path: String,
|
|
pub(crate) in_flight: bool,
|
|
pub(crate) error: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) enum SftpEditorInteraction {
|
|
Move {
|
|
pointer_origin: Point<Pixels>,
|
|
initial_bounds: Bounds<Pixels>,
|
|
},
|
|
Resize {
|
|
pointer_origin: Point<Pixels>,
|
|
initial_bounds: Bounds<Pixels>,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct SftpEditorState {
|
|
pub(crate) group_id: String,
|
|
pub(crate) remote_path: String,
|
|
pub(crate) raw_content: Vec<u8>,
|
|
pub(crate) original_content: String,
|
|
pub(crate) encoding: TextEncoding,
|
|
pub(crate) has_bom: bool,
|
|
pub(crate) decode_had_errors: bool,
|
|
pub(crate) loaded: bool,
|
|
pub(crate) loading: bool,
|
|
pub(crate) saving: bool,
|
|
pub(crate) message: Option<String>,
|
|
pub(crate) error: Option<String>,
|
|
pub(crate) bounds: Bounds<Pixels>,
|
|
pub(crate) interaction: Option<SftpEditorInteraction>,
|
|
}
|
|
|
|
impl Ashell {
|
|
fn transfer_source_title(&self, tab_id: &str) -> String {
|
|
self.tabs
|
|
.iter()
|
|
.find(|tab| tab.id == tab_id)
|
|
.map(|tab| tab.title.clone())
|
|
.or_else(|| {
|
|
self.tab_groups
|
|
.iter()
|
|
.find(|group| group.id == tab_id)
|
|
.map(|group| group.title.clone())
|
|
})
|
|
.or_else(|| {
|
|
self.tab_groups
|
|
.iter()
|
|
.find(|group| group.pane_root.contains(tab_id))
|
|
.map(|group| group.title.clone())
|
|
})
|
|
.unwrap_or_else(|| "Unknown".to_string())
|
|
}
|
|
|
|
pub(crate) fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
let host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("host")));
|
|
let session_name_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder("name (optional)"));
|
|
let port_input = cx.new(|cx| InputState::new(window, cx).default_value("22"));
|
|
let user_input = cx.new(|cx| InputState::new(window, cx).default_value("root"));
|
|
let password_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("password"))
|
|
.masked(true)
|
|
});
|
|
let key_path_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder("~/.ssh/id_ed25519"));
|
|
let key_inline_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.multi_line(true)
|
|
.rows(5)
|
|
.placeholder("-----BEGIN OPENSSH PRIVATE KEY-----")
|
|
});
|
|
let passphrase_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder("SSH private key passphrase (optional)")
|
|
.masked(true)
|
|
});
|
|
let baud_rate_input = cx.new(|cx| InputState::new(window, cx).default_value("115200"));
|
|
let proxy_host_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
|
|
let proxy_port_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string()));
|
|
let proxy_user_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string()));
|
|
let proxy_password_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("proxy_password").to_string())
|
|
.masked(true)
|
|
});
|
|
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
|
|
let sftp_new_folder_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
|
|
let sftp_rename_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_name").to_string()));
|
|
let sftp_editor_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.code_editor("text")
|
|
.line_number(true)
|
|
.soft_wrap(false)
|
|
});
|
|
let search_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder(t!("search").to_string()));
|
|
let remote_process_filter_input = cx
|
|
.new(|cx| InputState::new(window, cx).placeholder(t!("filter_processes").to_string()));
|
|
let remote_port_filter_input =
|
|
cx.new(|cx| InputState::new(window, cx).placeholder(t!("filter_ports").to_string()));
|
|
let connection_filter_input = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t!("filter_connections").to_string())
|
|
});
|
|
let connection_group_name_input = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t!("connection_group_name").to_string())
|
|
});
|
|
let command_history_filter_input = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t!("filter_command_history").to_string())
|
|
});
|
|
let config = ConfigStore::load().unwrap_or_else(|err| {
|
|
tracing::warn!("failed to load config: {err:#}");
|
|
ConfigStore::in_memory()
|
|
});
|
|
let global_proxy_host_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("proxy_host").to_string())
|
|
.default_value(config.global_proxy_host())
|
|
});
|
|
let global_proxy_port_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("proxy_port").to_string())
|
|
.default_value(
|
|
config
|
|
.global_proxy_port()
|
|
.map(|p| p.to_string())
|
|
.unwrap_or_default(),
|
|
)
|
|
});
|
|
let global_proxy_user_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("proxy_user").to_string())
|
|
.default_value(config.global_proxy_user())
|
|
});
|
|
let global_proxy_password_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("proxy_password").to_string())
|
|
.masked(true)
|
|
.default_value(config.global_proxy_password())
|
|
});
|
|
let sync_endpoint_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder("https://dav.example.com/ashell/")
|
|
.default_value(config.sync_endpoint())
|
|
});
|
|
let sync_username_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("sync_username").to_string())
|
|
.default_value(config.sync_username())
|
|
});
|
|
let sync_webdav_password_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("sync_webdav_password").to_string())
|
|
.masked(true)
|
|
});
|
|
let sync_s3_endpoint_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder("https://s3.example.com")
|
|
.default_value(config.sync_s3_endpoint())
|
|
});
|
|
let sync_s3_region_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder("us-east-1")
|
|
.default_value(config.sync_s3_region())
|
|
});
|
|
let sync_s3_bucket_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("sync_s3_bucket").to_string())
|
|
.default_value(config.sync_s3_bucket())
|
|
});
|
|
let sync_s3_object_key_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder("ashell-sync.json")
|
|
.default_value(config.sync_s3_object_key())
|
|
});
|
|
let sync_s3_access_key_input = cx.new(|cx| {
|
|
InputState::new(window, cx).placeholder(t!("sync_s3_access_key").to_string())
|
|
});
|
|
let sync_s3_secret_key_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("sync_s3_secret_key").to_string())
|
|
.masked(true)
|
|
});
|
|
let sync_s3_session_token_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("sync_s3_session_token").to_string())
|
|
.masked(true)
|
|
});
|
|
let sync_encryption_password_input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(t!("sync_encryption_password").to_string())
|
|
.masked(true)
|
|
});
|
|
|
|
let _subscriptions = vec![
|
|
cx.subscribe_in(&host_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&session_name_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&port_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&user_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&password_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&key_path_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&key_inline_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&baud_rate_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&proxy_host_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&proxy_port_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&proxy_user_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&proxy_password_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sftp_rename_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sftp_editor_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&search_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&remote_process_filter_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&remote_port_filter_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&connection_filter_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&connection_group_name_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&command_history_filter_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_endpoint_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_username_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_webdav_password_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_s3_endpoint_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_s3_region_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_s3_bucket_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_s3_object_key_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_s3_access_key_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_s3_secret_key_input, window, Self::on_input_event),
|
|
cx.subscribe_in(&sync_s3_session_token_input, window, Self::on_input_event),
|
|
cx.subscribe_in(
|
|
&sync_encryption_password_input,
|
|
window,
|
|
Self::on_input_event,
|
|
),
|
|
cx.observe_window_activation(window, Self::on_window_activation_changed),
|
|
cx.observe_window_bounds(window, Self::on_window_bounds_changed),
|
|
cx.on_app_quit(Self::save_layout_on_app_quit),
|
|
];
|
|
|
|
let (events_tx, events_rx) = mpsc::channel();
|
|
let workspace_panels = cx.new(|_| ResizableState::default());
|
|
let body_panels = cx.new(|_| ResizableState::default());
|
|
let sftp_tree_panels = cx.new(|_| ResizableState::default());
|
|
let sftp_file_columns = cx.new(|_| ResizableState::default());
|
|
let mut system_sampler = SystemSampler::new();
|
|
let system = system_sampler.sample();
|
|
let default_light_theme_name = ThemeRegistry::global(cx).default_light_theme().name.clone();
|
|
let default_dark_theme_name = ThemeRegistry::global(cx).default_dark_theme().name.clone();
|
|
let follow_system_theme = config.follow_system_theme();
|
|
|
|
let theme_mode = match config.theme_mode() {
|
|
"light" => ThemeMode::Light,
|
|
"dark" => ThemeMode::Dark,
|
|
_ => ThemeMode::Light,
|
|
};
|
|
let light_theme_name = if config.light_theme_name().is_empty() {
|
|
default_light_theme_name
|
|
} else {
|
|
config.light_theme_name().into()
|
|
};
|
|
let dark_theme_name = if config.dark_theme_name().is_empty() {
|
|
default_dark_theme_name
|
|
} else {
|
|
config.dark_theme_name().into()
|
|
};
|
|
|
|
let configured_locale = config.locale();
|
|
let mut active_locale = configured_locale.to_string();
|
|
if active_locale == "system" {
|
|
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
|
|
if active_locale.starts_with("zh") {
|
|
active_locale = "zh-CN".to_string();
|
|
} else {
|
|
active_locale = "en".to_string();
|
|
}
|
|
}
|
|
rust_i18n::set_locale(&active_locale);
|
|
gpui_component::set_locale(&active_locale);
|
|
connection_filter_input.update(cx, |input, cx| {
|
|
input.set_placeholder(t!("filter_connections").to_string(), window, cx);
|
|
});
|
|
connection_group_name_input.update(cx, |input, cx| {
|
|
input.set_placeholder(t!("connection_group_name").to_string(), window, cx);
|
|
});
|
|
command_history_filter_input.update(cx, |input, cx| {
|
|
input.set_placeholder(t!("filter_command_history").to_string(), window, cx);
|
|
});
|
|
remote_process_filter_input.update(cx, |input, cx| {
|
|
input.set_placeholder(t!("filter_processes").to_string(), window, cx);
|
|
});
|
|
remote_port_filter_input.update(cx, |input, cx| {
|
|
input.set_placeholder(t!("filter_ports").to_string(), window, cx);
|
|
});
|
|
let ui_font_family: SharedString = config.ui_font_family().into();
|
|
let terminal_font_family: SharedString = config.terminal_font_family().into();
|
|
let last_sidebar_width = Some(px(config
|
|
.workspace_panels()
|
|
.and_then(|s| s.first().copied())
|
|
.unwrap_or(constants::SIDEBAR_WIDTH)));
|
|
let mut this = Self {
|
|
focus_handle: cx.focus_handle(),
|
|
selector_focus_handle: cx.focus_handle(),
|
|
host_input,
|
|
session_name_input,
|
|
session_group: String::new(),
|
|
port_input,
|
|
user_input,
|
|
password_input,
|
|
key_path_input,
|
|
key_inline_input,
|
|
passphrase_input,
|
|
baud_rate_input,
|
|
session_protocol: "ssh".to_string(),
|
|
ssh_terminal_encoding: TextEncoding::Utf8,
|
|
ssh_proxy_type: "none".to_string(),
|
|
proxy_host_input,
|
|
proxy_port_input,
|
|
proxy_user_input,
|
|
proxy_password_input,
|
|
global_proxy_type: config.global_proxy_type().to_string(),
|
|
global_proxy_host_input,
|
|
global_proxy_port_input,
|
|
global_proxy_user_input,
|
|
global_proxy_password_input,
|
|
sync_endpoint_input,
|
|
sync_username_input,
|
|
sync_webdav_password_input,
|
|
sync_s3_endpoint_input,
|
|
sync_s3_region_input,
|
|
sync_s3_bucket_input,
|
|
sync_s3_object_key_input,
|
|
sync_s3_access_key_input,
|
|
sync_s3_secret_key_input,
|
|
sync_s3_session_token_input,
|
|
sync_encryption_password_input,
|
|
sync_in_progress: false,
|
|
sync_status: t!("sync_not_run").into(),
|
|
sftp_path_input,
|
|
remote_process_filter_input,
|
|
remote_port_filter_input,
|
|
connection_filter_input,
|
|
connection_group_name_input,
|
|
command_history_filter_input,
|
|
connection_management_mode: false,
|
|
selected_connection_ids: HashSet::new(),
|
|
selected_command_history: HashSet::new(),
|
|
ssh_auth_method: AuthMethod::Password,
|
|
ssh_config_entries: crate::session::ssh_config::parse_ssh_config().unwrap_or_default(),
|
|
ssh_config_selected: None,
|
|
editing_session_id: None,
|
|
editing_connection_group: None,
|
|
follow_system_theme,
|
|
theme_mode,
|
|
light_theme_name,
|
|
dark_theme_name,
|
|
ui_font_size: config.ui_font_size(),
|
|
terminal_font_size: config.terminal_font_size(),
|
|
terminal_zoom_accumulator: 0.0,
|
|
cursor_style: config.cursor_style(),
|
|
ui_font_family,
|
|
terminal_font_family,
|
|
tabs: Vec::new(),
|
|
active_tab: None,
|
|
tab_groups: Vec::new(),
|
|
active_group: None,
|
|
pane_root: PaneLayout::Single(String::new()),
|
|
focused_pane_path: Vec::new(),
|
|
terminal_panel_bounds: None,
|
|
selector_selection: 0,
|
|
workspace_panels,
|
|
body_panels,
|
|
sftp_tree_panels,
|
|
sftp_file_columns,
|
|
is_layout_reset: false,
|
|
terminal_scrollbars: HashMap::new(),
|
|
remote_files_scroll_handle: UniformListScrollHandle::new(),
|
|
remote_files_horizontal_scroll_handle: gpui::ScrollHandle::new(),
|
|
remote_files_columns_viewport_width: None,
|
|
remote_tree_scroll_handle: UniformListScrollHandle::new(),
|
|
disk_scroll_handle: gpui::ScrollHandle::new(),
|
|
process_scroll_handle: gpui::ScrollHandle::new(),
|
|
port_scroll_handle: UniformListScrollHandle::new(),
|
|
tabs_scroll_handle: gpui::ScrollHandle::new(),
|
|
tabs_viewport_bounds: None,
|
|
tab_scroll_animation_id: 0,
|
|
selector_scroll_handle: gpui::ScrollHandle::new(),
|
|
saved_scroll_handle: gpui::ScrollHandle::new(),
|
|
command_history_scroll_handle: gpui::ScrollHandle::new(),
|
|
connection_scroll_handle: gpui::ScrollHandle::new(),
|
|
saved_sessions_overflowing: false,
|
|
connection_progress: None,
|
|
pending_sftp_path_sync: Some("/".into()),
|
|
sftp_context_menu: None,
|
|
sftp_rename_input,
|
|
sftp_rename_state: None,
|
|
sftp_editor_input,
|
|
sftp_editor_state: None,
|
|
sftp_creating_folder: false,
|
|
sftp_new_folder_input,
|
|
sftp_delete_scroll_handle: gpui::ScrollHandle::new(),
|
|
show_hidden_files: config.show_hidden_files(),
|
|
transfers: {
|
|
let mut transfers = config.transfers();
|
|
for t in transfers.iter_mut() {
|
|
if matches!(
|
|
t.state,
|
|
crate::terminal::TransferState::Running
|
|
| crate::terminal::TransferState::Paused
|
|
) {
|
|
t.state =
|
|
crate::terminal::TransferState::Zombie(t!("zombie_reason").to_string());
|
|
}
|
|
}
|
|
transfers
|
|
},
|
|
show_transfers_dialog: false,
|
|
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(),
|
|
remote_process_status: None,
|
|
remote_processes_in_flight: false,
|
|
expanded_process_pid: None,
|
|
remote_ports: Vec::new(),
|
|
remote_ports_status: None,
|
|
remote_ports_in_flight: false,
|
|
terminating_processes: HashSet::new(),
|
|
terminal_bounds: HashMap::new(),
|
|
terminal_selecting: false,
|
|
terminal_marked_text: None,
|
|
dragging_splitter: None,
|
|
drag_split_origin: None,
|
|
sftp_panel_minimized: config.sftp_panel_minimized(),
|
|
sidebar_collapsed: config.sidebar_collapsed(),
|
|
window_active: window.is_window_active(),
|
|
should_move_window: false,
|
|
native_window_handle: crate::desktop_notification::native_window_handle(window),
|
|
unread_terminal_notifications: HashSet::new(),
|
|
prev_monitoring_size: None,
|
|
status: "ready".into(),
|
|
active_title_bar_style: config.title_bar_style(),
|
|
config,
|
|
system_sampler,
|
|
recording_action: None,
|
|
active_dialog: None,
|
|
keybind_error: None,
|
|
keybinds_suspended: false,
|
|
system,
|
|
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(),
|
|
|
|
search_input,
|
|
search_active: false,
|
|
search_query: String::new(),
|
|
search_matches: Vec::new(),
|
|
search_current: 0,
|
|
search_target_tab: None,
|
|
search_bar_bounds: None,
|
|
|
|
system_tab_id: None,
|
|
last_applied_system_tab_id: None,
|
|
sftp_handles: std::collections::HashMap::new(),
|
|
|
|
remote_sample_in_flight: false,
|
|
runtime: Runtime::new().expect("create tokio runtime"),
|
|
events_rx,
|
|
events_tx,
|
|
last_window_size: None,
|
|
last_sidebar_width,
|
|
pending_local_terminal_resizes: HashMap::new(),
|
|
local_terminal_resize_task: None,
|
|
hovered_url: None,
|
|
cmd_ctrl_pressed: false,
|
|
_subscriptions,
|
|
last_window_bounds: Some(window.window_bounds()),
|
|
save_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
|
|
save_latest_seq: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
};
|
|
|
|
let local_shell_fallback = this.normalize_local_terminal_shell();
|
|
this.apply_theme_preferences(window, cx);
|
|
this.restore_saved_tabs(window, cx);
|
|
if let Some(fallback) = local_shell_fallback {
|
|
this.status = t!(
|
|
"local_terminal_shell_fallback",
|
|
terminal = crate::session::local_terminal_shell_label(fallback)
|
|
)
|
|
.to_string()
|
|
.into();
|
|
}
|
|
this.report_active_terminal_focus(this.window_active);
|
|
this.start_event_pump(window, cx);
|
|
this
|
|
}
|
|
|
|
pub(crate) fn apply_loaded_config(
|
|
&mut self,
|
|
window: &mut gpui::Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.follow_system_theme = self.config.follow_system_theme();
|
|
self.theme_mode = match self.config.theme_mode() {
|
|
"light" => ThemeMode::Light,
|
|
"dark" => ThemeMode::Dark,
|
|
_ => ThemeMode::Dark,
|
|
};
|
|
self.light_theme_name = self.config.light_theme_name().to_string().into();
|
|
self.dark_theme_name = self.config.dark_theme_name().to_string().into();
|
|
self.ui_font_size = self.config.ui_font_size();
|
|
self.terminal_font_size = self.config.terminal_font_size();
|
|
self.cursor_style = self.config.cursor_style();
|
|
self.ui_font_family = self.config.ui_font_family().to_string().into();
|
|
self.terminal_font_family = self.config.terminal_font_family().to_string().into();
|
|
self.show_hidden_files = self.config.show_hidden_files();
|
|
self.sftp_panel_minimized = self.config.sftp_panel_minimized();
|
|
self.sidebar_collapsed = self.config.sidebar_collapsed();
|
|
self.active_title_bar_style = self.config.title_bar_style();
|
|
|
|
if let Some(fallback) = self.normalize_local_terminal_shell() {
|
|
self.status = t!(
|
|
"local_terminal_shell_fallback",
|
|
terminal = crate::session::local_terminal_shell_label(fallback)
|
|
)
|
|
.to_string()
|
|
.into();
|
|
}
|
|
|
|
// Apply theme preferences
|
|
self.apply_theme_preferences(window, cx);
|
|
|
|
// Update inputs
|
|
Self::set_input_value(
|
|
&self.sync_endpoint_input,
|
|
self.config.sync_endpoint().to_string(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(
|
|
&self.sync_username_input,
|
|
self.config.sync_username().to_string(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(
|
|
&self.sync_s3_endpoint_input,
|
|
self.config.sync_s3_endpoint().to_string(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(
|
|
&self.sync_s3_region_input,
|
|
self.config.sync_s3_region().to_string(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(
|
|
&self.sync_s3_bucket_input,
|
|
self.config.sync_s3_bucket().to_string(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(
|
|
&self.sync_s3_object_key_input,
|
|
self.config.sync_s3_object_key().to_string(),
|
|
window,
|
|
cx,
|
|
);
|
|
|
|
// Notify
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn on_input_event(
|
|
&mut self,
|
|
input: &Entity<InputState>,
|
|
event: &InputEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if input == &self.sftp_path_input {
|
|
if let InputEvent::PressEnter { .. } = event {
|
|
let path = self
|
|
.sftp_path_input
|
|
.read(cx)
|
|
.text()
|
|
.to_string()
|
|
.trim()
|
|
.to_string();
|
|
self.navigate_sftp(if path.is_empty() { "/".into() } else { path }, cx);
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
} else if input == &self.sftp_new_folder_input {
|
|
match event {
|
|
InputEvent::PressEnter { .. } => {
|
|
let name = self.sftp_new_folder_input.read(cx).text().to_string();
|
|
if !name.is_empty() {
|
|
let base_path = self.sftp_path_input.read(cx).text().to_string();
|
|
let path = crate::sftp::join_remote(&base_path, &name);
|
|
if let Some(handle) = self.active_sftp_handle() {
|
|
let _ = handle
|
|
.commands
|
|
.send(crate::sftp::SftpCommand::CreateDir(path));
|
|
}
|
|
}
|
|
self.sftp_creating_folder = false;
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
InputEvent::Blur => {
|
|
self.sftp_creating_folder = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
} else if input == &self.search_input {
|
|
if let InputEvent::PressEnter { .. } = event {
|
|
if self.search_query.is_empty()
|
|
|| *self.search_input.read(cx).text() != self.search_query
|
|
{
|
|
self.perform_search(window, cx);
|
|
} else {
|
|
self.search_goto_next(cx);
|
|
}
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn save_preferences_background(&mut self) {
|
|
let local_config = self.config.cache.clone();
|
|
let config_store = self.config.clone();
|
|
let latest_seq = self.save_latest_seq.clone();
|
|
let current_seq = latest_seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
|
let save_lock = self.save_lock.clone();
|
|
|
|
self.runtime.spawn(async move {
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
let Ok(_guard) = save_lock.lock() else {
|
|
tracing::error!("failed to lock preferences save state");
|
|
return;
|
|
};
|
|
if current_seq < latest_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
if let Err(err) = config_store.save_merged_preferences(local_config) {
|
|
tracing::error!("failed to save merged preferences in background: {err:#}");
|
|
}
|
|
})
|
|
.await;
|
|
});
|
|
}
|
|
|
|
fn expire_terminal_activity(&mut self, now: Instant) -> bool {
|
|
self.tabs.iter_mut().fold(false, |changed, tab| {
|
|
tab.expire_output_activity(now) || changed
|
|
})
|
|
}
|
|
|
|
/// Dispatch a terminal notification and retain its unread state while its tab is hidden.
|
|
fn handle_terminal_notification(&mut self, tab_id: &str, notification: TerminalNotification) {
|
|
let terminal_visible = self.is_terminal_visible(tab_id);
|
|
tracing::info!(
|
|
tab_id,
|
|
terminal_visible,
|
|
source = notification.source.as_str(),
|
|
"received terminal notification"
|
|
);
|
|
if !should_show_terminal_notification(
|
|
notification.occasion,
|
|
self.window_active,
|
|
terminal_visible,
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let fallback_title = self
|
|
.tabs
|
|
.iter()
|
|
.find(|tab| tab.id == tab_id)
|
|
.map(|tab| format!("ashell - {}", tab.title))
|
|
.unwrap_or_else(|| "ashell".to_string());
|
|
let title = notification.title.unwrap_or(fallback_title);
|
|
let body = notification.body.unwrap_or_default();
|
|
if !terminal_visible
|
|
&& self
|
|
.unread_terminal_notifications
|
|
.insert(tab_id.to_string())
|
|
{
|
|
self.update_unread_indicator();
|
|
}
|
|
crate::desktop_notification::show_terminal_notification(tab_id.to_string(), title, body);
|
|
}
|
|
|
|
fn handle_terminal_notification_activations(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> bool {
|
|
let mut handled = false;
|
|
while let Some(tab_id) =
|
|
crate::desktop_notification::take_terminal_notification_activation()
|
|
{
|
|
handled = true;
|
|
window.activate_window();
|
|
if self.tabs.iter().any(|tab| tab.id == tab_id) {
|
|
self.activate_tab(tab_id, window, cx);
|
|
} else {
|
|
tracing::info!(tab_id, "ignored activation for a closed terminal tab");
|
|
}
|
|
}
|
|
handled
|
|
}
|
|
|
|
fn is_terminal_visible(&self, tab_id: &str) -> bool {
|
|
if !self.window_active {
|
|
return false;
|
|
}
|
|
|
|
self.active_group
|
|
.as_ref()
|
|
.and_then(|group_id| self.tab_groups.iter().find(|group| &group.id == group_id))
|
|
.map(|group| group.pane_root.contains(tab_id))
|
|
.unwrap_or_else(|| self.active_tab.as_deref() == Some(tab_id))
|
|
}
|
|
|
|
fn update_unread_indicator(&self) {
|
|
crate::desktop_notification::set_unread_indicator(
|
|
!self.unread_terminal_notifications.is_empty(),
|
|
self.native_window_handle,
|
|
);
|
|
}
|
|
|
|
pub(crate) fn clear_visible_terminal_notifications(&mut self) {
|
|
if !self.window_active {
|
|
return;
|
|
}
|
|
|
|
let visible_tab_ids = self
|
|
.active_group
|
|
.as_ref()
|
|
.and_then(|group_id| self.tab_groups.iter().find(|group| &group.id == group_id))
|
|
.map(|group| {
|
|
group
|
|
.pane_root
|
|
.tab_ids()
|
|
.into_iter()
|
|
.collect::<HashSet<_>>()
|
|
})
|
|
.or_else(|| {
|
|
self.active_tab
|
|
.as_deref()
|
|
.map(|tab_id| HashSet::from([tab_id]))
|
|
})
|
|
.unwrap_or_default();
|
|
if retain_unread_terminal_notifications(&mut self.unread_terminal_notifications, |tab_id| {
|
|
!visible_tab_ids.contains(tab_id)
|
|
}) {
|
|
self.update_unread_indicator();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn clear_closed_terminal_notifications(&mut self) {
|
|
let open_tab_ids = self
|
|
.tabs
|
|
.iter()
|
|
.map(|tab| tab.id.as_str())
|
|
.collect::<HashSet<_>>();
|
|
if retain_unread_terminal_notifications(&mut self.unread_terminal_notifications, |tab_id| {
|
|
open_tab_ids.contains(tab_id)
|
|
}) {
|
|
self.update_unread_indicator();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn update_terminal_focus(&mut self, previous_tab_id: Option<&str>) {
|
|
let active_tab_id = self.active_tab.clone();
|
|
if previous_tab_id != active_tab_id.as_deref() {
|
|
if let Some(previous_tab_id) = previous_tab_id {
|
|
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == previous_tab_id) {
|
|
tab.report_focus(false);
|
|
}
|
|
}
|
|
self.report_active_terminal_focus(self.window_active);
|
|
}
|
|
self.clear_visible_terminal_notifications();
|
|
}
|
|
|
|
fn report_active_terminal_focus(&self, focused: bool) {
|
|
if let Some(tab) = self
|
|
.active_tab
|
|
.as_ref()
|
|
.and_then(|tab_id| self.tabs.iter().find(|tab| &tab.id == tab_id))
|
|
{
|
|
tab.report_focus(focused);
|
|
}
|
|
}
|
|
|
|
fn on_window_activation_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let was_active = self.window_active;
|
|
self.sync_window_activation(window);
|
|
if !was_active && self.window_active {
|
|
window.activate_window();
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn sync_window_activation(&mut self, window: &Window) -> bool {
|
|
let window_active = window.is_window_active();
|
|
if self.window_active == window_active {
|
|
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
|
|
}
|
|
|
|
pub(crate) fn start_event_pump(&self, window: &mut Window, cx: &mut Context<Self>) {
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let mut last_blink_time = std::time::Instant::now();
|
|
loop {
|
|
cx.background_executor()
|
|
.timer(Duration::from_millis(16))
|
|
.await;
|
|
if this
|
|
.update_in(cx, |this, window, cx| {
|
|
let terminal_notification_activated =
|
|
this.handle_terminal_notification_activations(window, cx);
|
|
let activation_changed = this.sync_window_activation(window);
|
|
let changed = this.drain_backend_events(cx);
|
|
let system_sampled = this.sample_system_if_due();
|
|
this.sync_theme_if_due(cx);
|
|
let is_blinking = matches!(
|
|
this.cursor_style,
|
|
crate::session::config::CursorStyle::Blink
|
|
| crate::session::config::CursorStyle::BeamBlink
|
|
);
|
|
let now = std::time::Instant::now();
|
|
let activity_changed = this.expire_terminal_activity(now);
|
|
let blink_due = is_blinking
|
|
&& now.duration_since(last_blink_time)
|
|
>= std::time::Duration::from_millis(600);
|
|
if terminal_notification_activated
|
|
|| activation_changed
|
|
|| changed
|
|
|| system_sampled
|
|
|| activity_changed
|
|
|| blink_due
|
|
{
|
|
cx.notify();
|
|
if blink_due {
|
|
last_blink_time = now;
|
|
}
|
|
}
|
|
})
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn drain_backend_events(&mut self, cx: &mut Context<Self>) -> bool {
|
|
let mut changed = false;
|
|
let mut transfers_changed = false;
|
|
while let Ok(event) = self.events_rx.try_recv() {
|
|
let Some(event) = event.into_current() else {
|
|
continue;
|
|
};
|
|
changed = true;
|
|
match event {
|
|
BackendEvent::Guarded { .. } => unreachable!("guarded events are unwrapped above"),
|
|
BackendEvent::Output { tab_id, bytes } => {
|
|
let notifications = self
|
|
.tabs
|
|
.iter_mut()
|
|
.find(|tab| tab.id == tab_id)
|
|
.map(|tab| tab.feed(&bytes))
|
|
.unwrap_or_default();
|
|
for notification in notifications {
|
|
self.handle_terminal_notification(&tab_id, notification);
|
|
}
|
|
}
|
|
BackendEvent::Status { tab_id, text } => {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
tab.status = text.clone();
|
|
}
|
|
if let Some(progress) = self.connection_progress.as_mut() {
|
|
if progress.tab_id == tab_id {
|
|
progress.lines.push(text.clone().into());
|
|
self.connection_scroll_handle
|
|
.set_offset(gpui::point(px(0.), px(-99999.0)));
|
|
}
|
|
}
|
|
self.status = text.into();
|
|
}
|
|
BackendEvent::Connected { tab_id } => {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
tab.connected = true;
|
|
tab.disconnected_reason = None;
|
|
}
|
|
self.sync_sftp_path_from_terminal_title(&tab_id, cx);
|
|
self.sync_sftp_to_active_tab();
|
|
self.sync_system_tab_to_active_group();
|
|
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
|
self.system_status = None;
|
|
self.remote_process_status = None;
|
|
self.remote_ports_status = None;
|
|
}
|
|
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();
|
|
}
|
|
if self
|
|
.connection_progress
|
|
.as_ref()
|
|
.is_some_and(|progress| progress.tab_id == tab_id && !progress.failed)
|
|
{
|
|
self.connection_progress = None;
|
|
}
|
|
}
|
|
BackendEvent::SftpEntries {
|
|
tab_id,
|
|
path,
|
|
entries,
|
|
} => {
|
|
let mut active_path = None;
|
|
if let Some(group) = self
|
|
.tab_groups
|
|
.iter_mut()
|
|
.find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str()))
|
|
{
|
|
if let Some(sftp) = group.sftp.as_mut() {
|
|
let normalized_path =
|
|
crate::sftp::normalize_remote_path(&path, "/", &sftp.home_dir);
|
|
let is_current = sftp.current_path == normalized_path;
|
|
sftp.apply_directory_entries(normalized_path, entries);
|
|
if is_current && self.active_group.as_deref() == Some(group.id.as_str())
|
|
{
|
|
active_path = Some(sftp.current_path.clone());
|
|
}
|
|
}
|
|
}
|
|
if let Some(path) = active_path {
|
|
self.pending_sftp_path_sync = Some(path);
|
|
}
|
|
}
|
|
BackendEvent::SftpDirectoryFailed {
|
|
tab_id,
|
|
path,
|
|
reason,
|
|
} => {
|
|
if let Some(group) = self
|
|
.tab_groups
|
|
.iter_mut()
|
|
.find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str()))
|
|
{
|
|
if let Some(sftp) = group.sftp.as_mut() {
|
|
let normalized_path =
|
|
crate::sftp::normalize_remote_path(&path, "/", &sftp.home_dir);
|
|
sftp.apply_directory_error(normalized_path, reason);
|
|
}
|
|
}
|
|
}
|
|
BackendEvent::SftpPreview { tab_id, preview } => {
|
|
if let Some(group) = self
|
|
.tab_groups
|
|
.iter_mut()
|
|
.find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str()))
|
|
{
|
|
if let Some(sftp) = group.sftp.as_mut() {
|
|
sftp.selected_path = Some(preview.path.clone());
|
|
sftp.preview = Some(preview);
|
|
}
|
|
}
|
|
}
|
|
BackendEvent::SftpStatus { tab_id, text } => {
|
|
let group_id = self
|
|
.tab_groups
|
|
.iter_mut()
|
|
.find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str()))
|
|
.map(|group| {
|
|
if let Some(sftp) = group.sftp.as_mut() {
|
|
sftp.status = text.clone();
|
|
}
|
|
group.id.clone()
|
|
});
|
|
if group_id
|
|
.as_ref()
|
|
.is_some_and(|group_id| self.active_group.as_ref() == Some(group_id))
|
|
{
|
|
self.status = text.into();
|
|
}
|
|
}
|
|
BackendEvent::RemoteSystem { tab_id, snapshot } => {
|
|
if self.is_connected_system_tab(&tab_id) {
|
|
self.remote_sample_in_flight = false;
|
|
self.system_status = None;
|
|
if self.last_applied_system_tab_id.as_deref() != Some(&tab_id) {
|
|
self.last_applied_system_tab_id = Some(tab_id.clone());
|
|
self.cpu_history.clear();
|
|
self.net_rx_history.clear();
|
|
self.net_tx_history.clear();
|
|
}
|
|
self.apply_system_snapshot(snapshot);
|
|
}
|
|
}
|
|
BackendEvent::RemoteSystemUnavailable { tab_id, reason } => {
|
|
if self.is_connected_system_tab(&tab_id) {
|
|
self.remote_sample_in_flight = false;
|
|
self.system_status = Some(reason.clone().into());
|
|
self.status = reason.into();
|
|
self.last_applied_system_tab_id = Some(tab_id);
|
|
self.system = SystemSnapshot::default();
|
|
self.cpu_history.clear();
|
|
self.net_rx_history.clear();
|
|
self.net_tx_history.clear();
|
|
}
|
|
}
|
|
BackendEvent::RemoteProcesses { tab_id, processes } => {
|
|
if self.is_connected_system_tab(&tab_id) {
|
|
self.remote_processes_in_flight = false;
|
|
self.remote_process_status = None;
|
|
self.remote_processes = processes;
|
|
self.sort_remote_processes();
|
|
}
|
|
}
|
|
BackendEvent::RemoteProcessesUnavailable { tab_id, reason } => {
|
|
if self.is_connected_system_tab(&tab_id) {
|
|
self.remote_processes_in_flight = false;
|
|
self.remote_processes.clear();
|
|
self.remote_process_status = Some(reason.clone().into());
|
|
self.status = reason.into();
|
|
}
|
|
}
|
|
BackendEvent::RemotePorts { tab_id, ports } => {
|
|
if self.is_connected_system_tab(&tab_id) {
|
|
self.remote_ports_in_flight = false;
|
|
self.remote_ports_status = None;
|
|
self.remote_ports = ports;
|
|
self.sort_remote_ports();
|
|
}
|
|
}
|
|
BackendEvent::RemotePortsUnavailable { tab_id, reason } => {
|
|
if self.is_connected_system_tab(&tab_id) {
|
|
self.remote_ports_in_flight = false;
|
|
self.remote_ports.clear();
|
|
self.remote_ports_status = Some(reason.clone().into());
|
|
self.status = reason.into();
|
|
}
|
|
}
|
|
BackendEvent::RemoteProcessTerminated { tab_id, pid } => {
|
|
if self.is_connected_system_tab(&tab_id) {
|
|
self.terminating_processes.remove(&pid);
|
|
self.remote_processes.retain(|process| process.pid != pid);
|
|
if self.expanded_process_pid == Some(pid) {
|
|
self.expanded_process_pid = None;
|
|
}
|
|
self.remote_process_status =
|
|
Some(t!("process_terminated", pid = pid).to_string().into());
|
|
self.status = t!("process_terminated", pid = pid).to_string().into();
|
|
self.request_active_process_snapshot();
|
|
}
|
|
}
|
|
BackendEvent::RemoteProcessTerminateFailed {
|
|
tab_id,
|
|
pid,
|
|
reason,
|
|
} => {
|
|
if self.is_connected_system_tab(&tab_id) {
|
|
self.terminating_processes.remove(&pid);
|
|
let message =
|
|
t!("process_terminate_failed", pid = pid, reason = reason).to_string();
|
|
self.remote_process_status = Some(message.clone().into());
|
|
self.status = message.into();
|
|
}
|
|
}
|
|
BackendEvent::Closed { tab_id, reason } => {
|
|
let is_graceful_exit =
|
|
reason == "local shell closed" || reason == "ssh session closed";
|
|
if is_graceful_exit {
|
|
self.handle_tab_close(tab_id.clone());
|
|
self.status = reason.into();
|
|
continue;
|
|
}
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
tab.clear_terminal_activity();
|
|
tab.connected = false;
|
|
tab.status = reason.clone();
|
|
tab.disconnected_reason = Some(reason.clone());
|
|
}
|
|
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
|
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());
|
|
}
|
|
if let Some(progress) = self.connection_progress.as_mut() {
|
|
if progress.tab_id == tab_id {
|
|
progress.lines.push(reason.clone().into());
|
|
self.connection_scroll_handle
|
|
.set_offset(gpui::point(px(0.), px(-99999.0)));
|
|
progress.title = t!("connection_failed").into();
|
|
progress.failed = true;
|
|
}
|
|
}
|
|
self.status = reason.into();
|
|
}
|
|
BackendEvent::TransferProgress {
|
|
id,
|
|
transferred,
|
|
total,
|
|
state,
|
|
} => {
|
|
if let Some(t) = self.transfers.iter_mut().find(|t| t.info.id == id) {
|
|
t.transferred = transferred;
|
|
if let Some(total) = total {
|
|
t.total = Some(total);
|
|
}
|
|
t.state = state;
|
|
transfers_changed = true;
|
|
}
|
|
}
|
|
BackendEvent::TransferStarted { tab_id, info } => {
|
|
let tab_title = self.transfer_source_title(&tab_id);
|
|
self.transfers.insert(
|
|
0,
|
|
crate::terminal::Transfer {
|
|
tab_id,
|
|
tab_title,
|
|
info,
|
|
transferred: 0,
|
|
total: None,
|
|
state: crate::terminal::TransferState::Running,
|
|
},
|
|
);
|
|
if self.transfers.len() > 100 {
|
|
self.transfers.truncate(100);
|
|
}
|
|
transfers_changed = true;
|
|
}
|
|
BackendEvent::SftpHome { tab_id, home } => {
|
|
let home = crate::sftp::normalize_remote_path(&home, "/", "/");
|
|
let mut group_id = None;
|
|
let mut paths_to_load = Vec::new();
|
|
let mut sync_active_path = false;
|
|
if let Some(group) = self
|
|
.tab_groups
|
|
.iter_mut()
|
|
.find(|group| group.sftp_tab_id.as_deref() == Some(tab_id.as_str()))
|
|
{
|
|
if let Some(sftp) = group.sftp.as_mut() {
|
|
sftp.home_dir = home.clone();
|
|
sftp.home_dir_resolved = true;
|
|
sftp.current_path = home.clone();
|
|
sftp.expand_to(&home);
|
|
sftp.begin_directory_load(&home);
|
|
for path in crate::sftp::remote_path_ancestors(&home) {
|
|
if path != home
|
|
&& !sftp.directory_cache.contains_key(&path)
|
|
&& !sftp.loading_directories.contains(&path)
|
|
{
|
|
sftp.begin_directory_load(&path);
|
|
paths_to_load.push(path);
|
|
}
|
|
}
|
|
group_id = Some(group.id.clone());
|
|
sync_active_path =
|
|
self.active_group.as_deref() == Some(group.id.as_str());
|
|
}
|
|
}
|
|
if let Some(handle) = group_id.as_ref().and_then(|id| self.sftp_handles.get(id))
|
|
{
|
|
for path in paths_to_load {
|
|
handle.list_dir(path);
|
|
}
|
|
}
|
|
if sync_active_path {
|
|
self.pending_sftp_path_sync = Some(home);
|
|
self.sync_sftp_path_from_terminal_title(&tab_id, cx);
|
|
}
|
|
}
|
|
BackendEvent::TerminalTitleChanged { tab_id, title } => {
|
|
let local_path = crate::session::parse_local_directory_title(&title);
|
|
if let Some(path) = local_path {
|
|
self.apply_local_directory_change(&tab_id, path);
|
|
} else if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
tab.dynamic_title = title;
|
|
tab.terminal_title_received = true;
|
|
}
|
|
self.sync_sftp_path_from_terminal_title(&tab_id, cx);
|
|
}
|
|
BackendEvent::TerminalBell { tab_id } => {
|
|
let body = t!("terminal_attention_required").to_string();
|
|
self.handle_terminal_notification(&tab_id, TerminalNotification::bell(body));
|
|
}
|
|
BackendEvent::LocalDirectoryChanged { tab_id, path } => {
|
|
self.apply_local_directory_change(&tab_id, path);
|
|
}
|
|
BackendEvent::SyncFinished(result) => {
|
|
self.sync_in_progress = false;
|
|
match result {
|
|
crate::sync::SyncResult::Uploaded { etag } => {
|
|
if etag.is_some() {
|
|
self.config.set_sync_etag(etag);
|
|
}
|
|
self.sync_status = t!("sync_upload_complete").into();
|
|
let _ = self.config.save();
|
|
}
|
|
crate::sync::SyncResult::Downloaded { payload, etag } => {
|
|
self.config.replace_sessions(payload.sessions);
|
|
self.selected_connection_ids.clear();
|
|
self.config.set_sync_etag(etag);
|
|
match self.config.save() {
|
|
Ok(()) => self.sync_status = t!("sync_download_complete").into(),
|
|
Err(err) => {
|
|
self.sync_status =
|
|
format!("{}: {err:#}", t!("sync_failed")).into()
|
|
}
|
|
}
|
|
}
|
|
crate::sync::SyncResult::Failed(error) => {
|
|
self.sync_status = format!("{}: {error}", t!("sync_failed")).into();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if transfers_changed {
|
|
self.config.set_transfers(self.transfers.clone());
|
|
}
|
|
changed
|
|
}
|
|
|
|
pub(crate) fn sample_system_if_due(&mut self) -> bool {
|
|
if self.last_system_sample.elapsed() >= SystemSampler::interval() {
|
|
self.last_system_sample = Instant::now();
|
|
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();
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
if self.last_applied_system_tab_id.is_some() {
|
|
self.last_applied_system_tab_id = None;
|
|
self.cpu_history.clear();
|
|
self.net_rx_history.clear();
|
|
self.net_tx_history.clear();
|
|
}
|
|
let snapshot = self.system_sampler.sample();
|
|
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.last_applied_system_tab_id = None;
|
|
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();
|
|
Theme::sync_system_appearance(None, cx);
|
|
cx.refresh_windows();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn request_active_system_snapshot(&mut self) {
|
|
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.connected_system_tab(tab_id).is_some()
|
|
}
|
|
|
|
pub(crate) fn request_active_process_snapshot(&mut self) {
|
|
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() {
|
|
self.remote_process_status = Some(t!("loading_processes").to_string().into());
|
|
}
|
|
backend.send(crate::terminal::BackendCommand::SampleProcesses);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn request_active_port_snapshot(&mut self) {
|
|
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() {
|
|
self.remote_ports_status = Some(t!("loading_ports").to_string().into());
|
|
}
|
|
backend.send(crate::terminal::BackendCommand::SamplePorts);
|
|
}
|
|
}
|
|
|
|
fn sort_remote_ports(&mut self) {
|
|
self.remote_ports.sort_by(|a, b| {
|
|
a.port
|
|
.cmp(&b.port)
|
|
.then_with(|| a.protocol.cmp(&b.protocol))
|
|
.then_with(|| a.address.cmp(&b.address))
|
|
.then_with(|| a.pid.cmp(&b.pid))
|
|
});
|
|
}
|
|
|
|
pub(crate) fn toggle_process_expanded(&mut self, pid: u32, cx: &mut Context<Self>) {
|
|
self.expanded_process_pid = (self.expanded_process_pid != Some(pid)).then_some(pid);
|
|
cx.notify();
|
|
}
|
|
|
|
fn sort_remote_processes(&mut self) {
|
|
match self.server_monitor_view {
|
|
ServerMonitorView::Cpu => self.remote_processes.sort_by(|a, b| {
|
|
b.cpu_percent
|
|
.total_cmp(&a.cpu_percent)
|
|
.then_with(|| a.pid.cmp(&b.pid))
|
|
}),
|
|
ServerMonitorView::Memory => self.remote_processes.sort_by(|a, b| {
|
|
b.memory_bytes
|
|
.cmp(&a.memory_bytes)
|
|
.then_with(|| a.pid.cmp(&b.pid))
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn terminate_remote_process(
|
|
&mut self,
|
|
tab_id: String,
|
|
pid: u32,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if pid <= 1 || self.terminating_processes.contains(&pid) {
|
|
return;
|
|
}
|
|
let Some(backend) = self
|
|
.connected_system_tab(&tab_id)
|
|
.map(|tab| tab.backend.clone())
|
|
else {
|
|
return;
|
|
};
|
|
if let Ok(backend) = backend.lock() {
|
|
self.terminating_processes.insert(pid);
|
|
self.remote_process_status =
|
|
Some(t!("terminating_process", pid = pid).to_string().into());
|
|
backend.send(crate::terminal::BackendCommand::TerminateProcess { pid });
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn terminal_ime_bounds_for_range(
|
|
&self,
|
|
range_utf16: Range<usize>,
|
|
element_bounds: Bounds<Pixels>,
|
|
cell_width: f32,
|
|
line_height: f32,
|
|
) -> Option<Bounds<Pixels>> {
|
|
let snapshot = self.active_snapshot()?;
|
|
let cursor = snapshot.cursor?;
|
|
let x = element_bounds.origin.x
|
|
+ px(cell_width) * cursor.col as f32
|
|
+ px(cell_width) * range_utf16.start as f32;
|
|
let y = element_bounds.origin.y + px(line_height) * cursor.row as f32;
|
|
Some(Bounds::new(
|
|
point(x, y),
|
|
size(px(cell_width), px(line_height)),
|
|
))
|
|
}
|
|
|
|
pub(crate) fn remove_transfer(&mut self, transfer_id: &str, cx: &mut Context<Self>) {
|
|
self.transfers.retain(|t| t.info.id != transfer_id);
|
|
self.config.set_transfers(self.transfers.clone());
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
|
|
let Some(progress) = self.connection_progress.clone() else {
|
|
return;
|
|
};
|
|
self.connection_progress = None;
|
|
let mut retry_tabs = Vec::new();
|
|
for (ix, tab) in self.tabs.iter().enumerate() {
|
|
if !tab.connected && tab.session.is_some() && tab.id == progress.tab_id {
|
|
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap(), tab.kind));
|
|
}
|
|
}
|
|
|
|
if retry_tabs.is_empty() {
|
|
cx.notify();
|
|
return;
|
|
}
|
|
|
|
for (ix, tab_id, session, tab_kind) in retry_tabs {
|
|
let backend_events = self.tabs[ix].advance_backend_events();
|
|
// Invalidate old backend events before requesting shutdown.
|
|
self.tabs[ix].send_backend(crate::terminal::BackendCommand::Close);
|
|
|
|
// Spawn new backend
|
|
let backend = match tab_kind {
|
|
crate::terminal::TabKind::Serial => {
|
|
let b = crate::backend::serial::spawn_serial_client(
|
|
self.runtime.handle(),
|
|
tab_id.clone(),
|
|
session.clone(),
|
|
backend_events.clone(),
|
|
);
|
|
crate::terminal::BackendTx::Serial(b)
|
|
}
|
|
crate::terminal::TabKind::Ssh => crate::backend::ssh::spawn_ssh_terminal(
|
|
self.runtime.handle(),
|
|
tab_id.clone(),
|
|
session.clone(),
|
|
self.tabs[ix].cols,
|
|
self.tabs[ix].rows,
|
|
backend_events.clone(),
|
|
),
|
|
_ => continue,
|
|
};
|
|
|
|
// Replace tab state
|
|
self.tabs[ix].set_backend(backend);
|
|
self.tabs[ix].connected = false;
|
|
self.tabs[ix].status = "connecting".into();
|
|
self.tabs[ix].disconnected_reason = None;
|
|
self.tabs[ix].terminal_title_received = false;
|
|
|
|
if tab_kind == crate::terminal::TabKind::Ssh
|
|
&& self.active_tab.as_deref() == Some(tab_id.as_str())
|
|
{
|
|
self.restart_active_sftp();
|
|
}
|
|
}
|
|
|
|
self.connection_progress = Some(ConnectionProgress {
|
|
tab_id: progress.tab_id.clone(),
|
|
title: t!("connecting").into(),
|
|
lines: vec![t!("starting_connection").into()],
|
|
failed: false,
|
|
});
|
|
self.status = "ssh tabs retrying".into();
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
|
|
if let Some(progress) = &self.connection_progress {
|
|
let tab_id = progress.tab_id.clone();
|
|
self.connection_progress = None;
|
|
self.handle_tab_close(tab_id);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn sync_cwd_from_terminal(
|
|
&mut self,
|
|
_window: &mut gpui::Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let active_id = self.active_tab.clone();
|
|
let Some(active_id) = active_id else {
|
|
return;
|
|
};
|
|
|
|
let parsed = if let Some(tab) = self.tabs.iter().find(|t| t.id == active_id) {
|
|
let home_dir = if let Some(group) = self
|
|
.tab_groups
|
|
.iter()
|
|
.find(|g| g.pane_root.contains(&tab.id))
|
|
{
|
|
group
|
|
.sftp
|
|
.as_ref()
|
|
.map(|s| s.home_dir.as_str())
|
|
.unwrap_or("/")
|
|
} else {
|
|
"/"
|
|
};
|
|
|
|
Self::parse_path_from_title(&tab.dynamic_title, home_dir)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(path) = parsed {
|
|
self.navigate_sftp(path, cx);
|
|
}
|
|
}
|
|
|
|
fn sync_sftp_path_from_terminal_title(&mut self, tab_id: &str, cx: &mut Context<Self>) {
|
|
if self.active_tab.as_deref() != Some(tab_id) {
|
|
return;
|
|
}
|
|
|
|
let Some(tab) = self.tabs.iter().find(|tab| tab.id == tab_id) else {
|
|
return;
|
|
};
|
|
if tab.kind != TabKind::Ssh
|
|
|| !tab.connected
|
|
|| !tab.terminal_title_received
|
|
|| tab.is_alternate_screen_active()
|
|
{
|
|
return;
|
|
}
|
|
let title = tab.dynamic_title.clone();
|
|
|
|
let Some((current_path, home_dir)) = self
|
|
.active_group
|
|
.as_ref()
|
|
.and_then(|group_id| {
|
|
self.tab_groups
|
|
.iter()
|
|
.find(|group| &group.id == group_id && group.pane_root.contains(tab_id))
|
|
})
|
|
.and_then(|group| group.sftp.as_ref())
|
|
.filter(|sftp| sftp.home_dir_resolved)
|
|
.map(|sftp| (sftp.current_path.clone(), sftp.home_dir.clone()))
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let Some(path) = Self::parse_path_from_title(&title, &home_dir) else {
|
|
return;
|
|
};
|
|
let path = crate::sftp::normalize_remote_path(&path, ¤t_path, &home_dir);
|
|
if path != current_path {
|
|
self.navigate_sftp(path, cx);
|
|
}
|
|
}
|
|
|
|
fn parse_path_from_title(title: &str, home_dir: &str) -> Option<String> {
|
|
let title = title.strip_prefix("ASHELL_CWD:").unwrap_or(title);
|
|
let path_part = if let Some(pos) = title.find(':') {
|
|
title[pos + 1..].trim()
|
|
} else {
|
|
title.trim()
|
|
};
|
|
|
|
if path_part.starts_with('/') {
|
|
Some(path_part.to_string())
|
|
} else if path_part == "~" {
|
|
Some(home_dir.to_string())
|
|
} else if let Some(rest) = path_part.strip_prefix("~/") {
|
|
let home = home_dir.trim_end_matches('/');
|
|
Some(format!("{}/{}", home, rest))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn capture_layout_state(&mut self, window: &mut gpui::Window, cx: &gpui::App) -> bool {
|
|
if self.is_layout_reset {
|
|
tracing::info!("[ui] layout was reset, skipping save layout state.");
|
|
return false;
|
|
}
|
|
let current_bounds = window.window_bounds();
|
|
let bounds = match current_bounds {
|
|
gpui::WindowBounds::Fullscreen(b) => b,
|
|
gpui::WindowBounds::Maximized(b) => b,
|
|
gpui::WindowBounds::Windowed(b) => b,
|
|
};
|
|
let size = bounds.size;
|
|
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
|
|
let saved_bounds = match current_bounds {
|
|
gpui::WindowBounds::Fullscreen(b) => {
|
|
crate::session::config::SavedWindowBounds::Fullscreen {
|
|
x: b.origin.x.into(),
|
|
y: b.origin.y.into(),
|
|
width: b.size.width.into(),
|
|
height: b.size.height.into(),
|
|
}
|
|
}
|
|
gpui::WindowBounds::Maximized(b) => {
|
|
crate::session::config::SavedWindowBounds::Maximized {
|
|
x: b.origin.x.into(),
|
|
y: b.origin.y.into(),
|
|
width: b.size.width.into(),
|
|
height: b.size.height.into(),
|
|
}
|
|
}
|
|
gpui::WindowBounds::Windowed(b) => {
|
|
crate::session::config::SavedWindowBounds::Windowed {
|
|
x: b.origin.x.into(),
|
|
y: b.origin.y.into(),
|
|
width: b.size.width.into(),
|
|
height: b.size.height.into(),
|
|
}
|
|
}
|
|
};
|
|
let workspace_sizes: Vec<f32> = self
|
|
.workspace_panels
|
|
.read(cx)
|
|
.sizes()
|
|
.iter()
|
|
.map(|s| s.into())
|
|
.collect();
|
|
let mut body_sizes: Vec<f32> = self
|
|
.body_panels
|
|
.read(cx)
|
|
.sizes()
|
|
.iter()
|
|
.map(|s| s.into())
|
|
.collect();
|
|
|
|
if body_sizes.len() < 2 {
|
|
body_sizes = self.config.body_panels().cloned().unwrap_or_default();
|
|
}
|
|
|
|
let mut sftp_tree_sizes: Vec<f32> = self
|
|
.sftp_tree_panels
|
|
.read(cx)
|
|
.sizes()
|
|
.iter()
|
|
.map(|size| size.as_f32())
|
|
.collect();
|
|
if sftp_tree_sizes.len() < 2 {
|
|
sftp_tree_sizes = self.config.sftp_tree_panels().cloned().unwrap_or_default();
|
|
}
|
|
|
|
let current_sftp_file_column_sizes: Vec<f32> = self
|
|
.sftp_file_columns
|
|
.read(cx)
|
|
.sizes()
|
|
.iter()
|
|
.map(|size| size.as_f32())
|
|
.collect();
|
|
let mut sftp_file_column_sizes = self
|
|
.config
|
|
.sftp_file_columns()
|
|
.filter(|sizes| sizes.len() >= 3)
|
|
.cloned()
|
|
.unwrap_or_else(|| vec![200., 64., 128.]);
|
|
for (index, size) in current_sftp_file_column_sizes
|
|
.into_iter()
|
|
.take(3)
|
|
.enumerate()
|
|
{
|
|
sftp_file_column_sizes[index] = size;
|
|
}
|
|
|
|
if self.sftp_panel_minimized {
|
|
if let Some(prev) = self.prev_monitoring_size {
|
|
if body_sizes.len() > 1 {
|
|
body_sizes[1] = prev.into();
|
|
}
|
|
}
|
|
}
|
|
|
|
let body_sizes = (!body_sizes.is_empty()).then_some(body_sizes);
|
|
let sftp_tree_sizes = (!sftp_tree_sizes.is_empty()).then_some(sftp_tree_sizes);
|
|
let sftp_file_column_sizes =
|
|
(!sftp_file_column_sizes.is_empty()).then_some(sftp_file_column_sizes);
|
|
self.config
|
|
.set_layout_state(Some(saved_bounds), Some(workspace_sizes), body_sizes);
|
|
self.config.set_sftp_tree_panels(sftp_tree_sizes);
|
|
self.config.set_sftp_file_columns(sftp_file_column_sizes);
|
|
self.config.set_sidebar_collapsed(self.sidebar_collapsed);
|
|
self.config
|
|
.set_sftp_panel_minimized(self.sftp_panel_minimized);
|
|
true
|
|
} else {
|
|
tracing::warn!(
|
|
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
|
|
size
|
|
);
|
|
false
|
|
}
|
|
}
|
|
|
|
pub(crate) fn save_layout_state(&mut self, window: &mut gpui::Window, cx: &gpui::App) {
|
|
let should_save_tabs = self.config.remember_tabs();
|
|
if should_save_tabs {
|
|
self.capture_tabs_state();
|
|
}
|
|
if self.capture_layout_state(window, cx) || should_save_tabs {
|
|
let current_seq = self
|
|
.save_latest_seq
|
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
|
+ 1;
|
|
let Ok(_guard) = self.save_lock.lock() else {
|
|
tracing::error!("failed to lock window layout save state");
|
|
return;
|
|
};
|
|
if current_seq
|
|
< self
|
|
.save_latest_seq
|
|
.load(std::sync::atomic::Ordering::SeqCst)
|
|
{
|
|
return;
|
|
}
|
|
if let Err(err) = self.config.save() {
|
|
tracing::error!("failed to save window layout state: {err:#}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_window_bounds_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let current_bounds = window.window_bounds();
|
|
|
|
// Keep a reset effective until the first actual bounds change. GPUI may
|
|
// notify observers during a redraw without changing the window geometry.
|
|
if self.is_layout_reset && self.last_window_bounds != Some(current_bounds) {
|
|
self.is_layout_reset = false;
|
|
}
|
|
|
|
if self.last_window_bounds == Some(current_bounds) {
|
|
return;
|
|
}
|
|
self.last_window_bounds = Some(current_bounds);
|
|
|
|
// Save synchronously on the main thread. Background saves are killed by
|
|
// ExitProcess on Windows before they can flush during shutdown.
|
|
if self.capture_layout_state(window, cx) {
|
|
self.save_layout_state(window, cx);
|
|
}
|
|
}
|
|
|
|
fn save_layout_on_app_quit(&mut self, cx: &mut Context<Self>) -> gpui::Task<()> {
|
|
crate::desktop_notification::clear_unread_indicator(self.native_window_handle);
|
|
let entity_id = cx.entity_id();
|
|
// Save synchronously: GPUI gives quit handlers only 100ms, and Windows
|
|
// ExitProcess kills background tasks before they can flush.
|
|
let _ = cx.with_window(entity_id, |window, cx| {
|
|
self.save_layout_state(window, cx);
|
|
});
|
|
gpui::Task::ready(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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() {
|
|
assert!(!should_show_terminal_notification(
|
|
TerminalNotificationOccasion::Always,
|
|
true,
|
|
true,
|
|
));
|
|
assert!(should_show_terminal_notification(
|
|
TerminalNotificationOccasion::Always,
|
|
true,
|
|
false,
|
|
));
|
|
assert!(!should_show_terminal_notification(
|
|
TerminalNotificationOccasion::Unfocused,
|
|
true,
|
|
false,
|
|
));
|
|
assert!(should_show_terminal_notification(
|
|
TerminalNotificationOccasion::Unfocused,
|
|
false,
|
|
false,
|
|
));
|
|
assert!(!should_show_terminal_notification(
|
|
TerminalNotificationOccasion::Invisible,
|
|
true,
|
|
true,
|
|
));
|
|
assert!(should_show_terminal_notification(
|
|
TerminalNotificationOccasion::Invisible,
|
|
true,
|
|
false,
|
|
));
|
|
}
|
|
}
|