mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df44d17ade | ||
|
|
0e4c4cdc69 | ||
|
|
227966319c | ||
|
|
205ceba20c | ||
|
|
0ccbf9573b | ||
|
|
8193223dc2 | ||
|
|
805cec5000 | ||
|
|
3151757c4f | ||
|
|
57c55e358c | ||
|
|
81b160285d | ||
|
|
77206bd126 | ||
|
|
bb22718d3d | ||
|
|
4ba3000538 | ||
|
|
e7e2c67393 | ||
|
|
47b03510b2 | ||
|
|
54c50c871a | ||
|
|
6f887712e6 | ||
|
|
99071997d5 | ||
|
|
e35a2458bb | ||
|
|
690d7418f4 | ||
|
|
aca03c924f | ||
|
|
1847736d66 | ||
|
|
e163d05683 | ||
|
|
c3b1707014 | ||
|
|
8a82288c5e | ||
|
|
8c24eb885c | ||
|
|
2afcd1ea90 |
@@ -68,6 +68,10 @@ system_default: "System Default"
|
||||
ui_font_family: "UI Font"
|
||||
terminal_font_family: "Terminal Font"
|
||||
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
|
||||
monitoring_position: "Monitoring Position"
|
||||
position_bottom: "Bottom"
|
||||
position_sidebar: "Sidebar"
|
||||
position_hidden: "Hidden"
|
||||
local_host: "local host"
|
||||
local_terminal_opened: "local terminal opened"
|
||||
connecting: "Connecting"
|
||||
@@ -109,3 +113,4 @@ copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/
|
||||
edit_file: "Edit File"
|
||||
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
|
||||
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
|
||||
scroll: "Scroll ↓"
|
||||
|
||||
+6
-1
@@ -69,7 +69,11 @@ terminal_font_size: "终端字体大小"
|
||||
system_default: "系统默认"
|
||||
ui_font_family: "界面字体"
|
||||
terminal_font_family: "终端字体"
|
||||
theme_management_hint: "主题模式和亮/暗主题系列可通过顶栏的主题按钮管理。"
|
||||
theme_management_hint: "深色/浅色模式以及主题配色由顶部栏的样式按钮统一管理。"
|
||||
monitoring_position: "系统监控显示位置"
|
||||
position_bottom: "底部"
|
||||
position_sidebar: "侧边栏"
|
||||
position_hidden: "不显示"
|
||||
local_host: "本地主机"
|
||||
local_terminal_opened: "已打开本地终端"
|
||||
connecting: "正在连接"
|
||||
@@ -111,3 +115,4 @@ copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key}
|
||||
edit_file: "编辑文件"
|
||||
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
|
||||
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
|
||||
scroll: "滑动查看 ↓"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
pub(crate) const DEFAULT_COLS: u16 = 100;
|
||||
pub(crate) const DEFAULT_ROWS: u16 = 30;
|
||||
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
|
||||
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
|
||||
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
|
||||
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
|
||||
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
|
||||
+1310
File diff suppressed because it is too large
Load Diff
+829
@@ -0,0 +1,829 @@
|
||||
pub mod constants;
|
||||
pub mod dialogs;
|
||||
pub mod startup;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
ops::Range,
|
||||
rc::Rc,
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
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},
|
||||
resizable::ResizableState,
|
||||
scroll::ScrollbarHandle,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, ConfigStore},
|
||||
system::{SystemSampler, SystemSnapshot},
|
||||
terminal::{self, BackendEvent, TabKind, TerminalTab},
|
||||
backend::ssh,
|
||||
};
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
impl PaneLayout {
|
||||
pub fn tab_ids(&self) -> Vec<&str> {
|
||||
match self {
|
||||
PaneLayout::Single(id) => vec![id.as_str()],
|
||||
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
||||
children.iter().flat_map(|c| c.tab_ids()).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) -> bool {
|
||||
match self {
|
||||
PaneLayout::Single(id) if id == tab_id => {
|
||||
*self = PaneLayout::Single(String::new());
|
||||
true
|
||||
}
|
||||
PaneLayout::Single(_) => false,
|
||||
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;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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) 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) sftp_path_input: Entity<InputState>,
|
||||
pub(crate) ssh_auth_method: AuthMethod,
|
||||
pub(crate) editing_session_id: 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) 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) terminal_scrollbars: HashMap<String, TerminalScrollbarHandle>,
|
||||
pub(crate) remote_files_scroll_handle: UniformListScrollHandle,
|
||||
pub(crate) disk_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) tabs_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) selector_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) saved_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) connection_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) connection_progress: Option<ConnectionProgress>,
|
||||
pub(crate) pending_sftp_path_sync: Option<String>,
|
||||
pub(crate) sftp_context_menu: Option<SftpContextMenuState>,
|
||||
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) system_status: Option<SharedString>,
|
||||
pub(crate) pane_root: PaneLayout,
|
||||
pub(crate) focused_pane_path: Vec<usize>,
|
||||
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) drag_split_origin: Option<gpui::Point<Pixels>>,
|
||||
pub(crate) terminal_marked_text: Option<String>,
|
||||
pub(crate) sftp_panel_minimized: bool,
|
||||
pub(crate) prev_monitoring_size: Option<Pixels>,
|
||||
pub(crate) status: SharedString,
|
||||
pub(crate) config: ConfigStore,
|
||||
pub(crate) system_sampler: SystemSampler,
|
||||
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) 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) _subscriptions: Vec<gpui::Subscription>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum SelectorEntry {
|
||||
Local,
|
||||
NewSsh,
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
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 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 _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(&sftp_path_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
|
||||
];
|
||||
|
||||
let (events_tx, events_rx) = mpsc::channel();
|
||||
let workspace_panels = cx.new(|_| ResizableState::default());
|
||||
let body_panels = 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 config = ConfigStore::load().unwrap_or_else(|err| {
|
||||
tracing::warn!("failed to load config: {err:#}");
|
||||
ConfigStore::in_memory()
|
||||
});
|
||||
let follow_system_theme =
|
||||
if config.light_theme_name().is_empty() && config.dark_theme_name().is_empty() {
|
||||
true
|
||||
} else {
|
||||
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);
|
||||
let ui_font_family: SharedString = config.ui_font_family().into();
|
||||
let terminal_font_family: SharedString = config.terminal_font_family().into();
|
||||
let mut this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
selector_focus_handle: cx.focus_handle(),
|
||||
host_input,
|
||||
session_name_input,
|
||||
port_input,
|
||||
user_input,
|
||||
password_input,
|
||||
key_path_input,
|
||||
key_inline_input,
|
||||
sftp_path_input,
|
||||
ssh_auth_method: AuthMethod::Password,
|
||||
editing_session_id: 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(),
|
||||
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(),
|
||||
selector_selection: 0,
|
||||
workspace_panels,
|
||||
body_panels,
|
||||
terminal_scrollbars: HashMap::new(),
|
||||
remote_files_scroll_handle: UniformListScrollHandle::new(),
|
||||
disk_scroll_handle: gpui::ScrollHandle::new(),
|
||||
tabs_scroll_handle: gpui::ScrollHandle::new(),
|
||||
selector_scroll_handle: gpui::ScrollHandle::new(),
|
||||
saved_scroll_handle: gpui::ScrollHandle::new(),
|
||||
connection_scroll_handle: gpui::ScrollHandle::new(),
|
||||
connection_progress: None,
|
||||
pending_sftp_path_sync: Some("/".into()),
|
||||
sftp_context_menu: None,
|
||||
sftp_creating_folder: false,
|
||||
sftp_new_folder_input,
|
||||
sftp_delete_scroll_handle: gpui::ScrollHandle::new(),
|
||||
show_hidden_files: config.show_hidden_files(),
|
||||
transfers: config.transfers(),
|
||||
show_transfers_dialog: false,
|
||||
system_status: None,
|
||||
terminal_bounds: HashMap::new(),
|
||||
terminal_selecting: false,
|
||||
terminal_marked_text: None,
|
||||
dragging_splitter: None,
|
||||
drag_split_origin: None,
|
||||
sftp_panel_minimized: false,
|
||||
prev_monitoring_size: None,
|
||||
status: "ready".into(),
|
||||
config,
|
||||
system_sampler,
|
||||
system,
|
||||
cpu_history: Vec::with_capacity(20),
|
||||
net_rx_history: Vec::with_capacity(20),
|
||||
net_tx_history: Vec::with_capacity(20),
|
||||
last_system_sample: Instant::now(),
|
||||
last_theme_sync: Instant::now(),
|
||||
|
||||
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,
|
||||
_subscriptions,
|
||||
};
|
||||
|
||||
this.apply_theme_preferences(window, cx);
|
||||
// this.open_local(cx);
|
||||
this.start_event_pump(cx);
|
||||
this
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let mut idle_frames = 0u32;
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(16))
|
||||
.await;
|
||||
if this
|
||||
.update(cx, |this, cx| {
|
||||
let changed = this.drain_backend_events();
|
||||
let system_sampled = this.sample_system_if_due();
|
||||
this.sync_theme_if_due(cx);
|
||||
if changed || system_sampled {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
} else {
|
||||
idle_frames += 1;
|
||||
if idle_frames >= 60 {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
}
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn drain_backend_events(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
let mut transfers_changed = false;
|
||||
while let Ok(event) = self.events_rx.try_recv() {
|
||||
changed = true;
|
||||
match event {
|
||||
BackendEvent::Output { tab_id, bytes } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.feed(&bytes);
|
||||
}
|
||||
}
|
||||
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());
|
||||
let _idx = progress.lines.len().saturating_sub(1);
|
||||
self.connection_scroll_handle.set_offset(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;
|
||||
}
|
||||
self.sync_system_tab_to_active_group();
|
||||
self.request_active_system_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,
|
||||
} => {
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.current_path = path;
|
||||
sftp.entries = entries;
|
||||
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::SftpPreview { tab_id, preview } => {
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.selected_path = Some(preview.path.clone());
|
||||
sftp.preview = Some(preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::SftpStatus { tab_id, text } => {
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.status = text.clone();
|
||||
}
|
||||
}
|
||||
if self.active_group.as_ref() == Some(&tab_id) {
|
||||
self.status = text.into();
|
||||
}
|
||||
}
|
||||
BackendEvent::RemoteSystem { tab_id, snapshot } => {
|
||||
self.remote_sample_in_flight = false;
|
||||
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::RemoteSystemUnavailable { tab_id, reason } => {
|
||||
self.remote_sample_in_flight = false;
|
||||
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
||||
self.system_status = Some(reason.clone().into());
|
||||
self.status = reason.into();
|
||||
}
|
||||
}
|
||||
BackendEvent::Closed { tab_id, reason } => {
|
||||
self.remote_sample_in_flight = false;
|
||||
let mut tab_title = None;
|
||||
let mut session_label = None;
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.connected = false;
|
||||
tab.status = reason.clone();
|
||||
tab_title = Some(tab.title.clone());
|
||||
session_label = tab.session.as_ref().map(|session| {
|
||||
format!("{}@{}:{}", session.user, session.host, session.port)
|
||||
});
|
||||
}
|
||||
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
||||
self.system_status = Some(reason.clone().into());
|
||||
}
|
||||
let is_graceful_exit = reason == "local shell closed"
|
||||
|| reason == "ssh session closed";
|
||||
// Auto-close the pane on graceful exit (e.g. user typed exit)
|
||||
if is_graceful_exit {
|
||||
self.handle_tab_close(tab_id.clone());
|
||||
self.status = reason.into();
|
||||
self.remote_sample_in_flight = false;
|
||||
return changed;
|
||||
}
|
||||
let mut needs_new_progress = false;
|
||||
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(point(px(0.), px(-99999.0)));
|
||||
let _ = session_label;
|
||||
let _ = tab_title;
|
||||
progress.title = t!("connection_failed").into();
|
||||
progress.failed = true;
|
||||
} else if !progress.failed {
|
||||
// We were showing connecting progress, but another tab dropped!
|
||||
// Switch to failed state so the user can see it and retry.
|
||||
progress.tab_id = tab_id.clone();
|
||||
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
|
||||
progress.lines.push(msg.into());
|
||||
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
|
||||
progress.title = t!("connection_failed").into();
|
||||
progress.failed = true;
|
||||
} else {
|
||||
// Already showing a failure dialog, just append the new failure
|
||||
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
|
||||
progress.lines.push(msg.into());
|
||||
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
|
||||
}
|
||||
} else if let Some(_) = session_label {
|
||||
needs_new_progress = true;
|
||||
}
|
||||
|
||||
if needs_new_progress && !is_graceful_exit {
|
||||
self.connection_progress = Some(ConnectionProgress {
|
||||
tab_id: tab_id.clone(),
|
||||
title: t!("connection_failed").into(),
|
||||
lines: vec![reason.clone().into()],
|
||||
failed: true,
|
||||
});
|
||||
}
|
||||
self.status = reason.into();
|
||||
}
|
||||
BackendEvent::TransferProgress {
|
||||
tab_id: _,
|
||||
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
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|t| t.id == tab_id)
|
||||
.map(|t| t.title.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
self.transfers.insert(
|
||||
0,
|
||||
crate::terminal::Transfer {
|
||||
tab_id,
|
||||
tab_title,
|
||||
info,
|
||||
transferred: 0,
|
||||
total: None,
|
||||
state: crate::terminal::TransferState::Running,
|
||||
},
|
||||
);
|
||||
transfers_changed = true;
|
||||
}
|
||||
BackendEvent::SftpHome { tab_id, home } => {
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.home_dir = home;
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::TerminalTitleChanged { tab_id, title } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.title = title.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
// Use system_tab_id (not active_tab) to decide remote vs local sampling
|
||||
if let Some(ref tab_id) = self.system_tab_id.clone() {
|
||||
if self.tabs.iter().any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected) && self.system_status.is_none() {
|
||||
self.request_active_system_snapshot();
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
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) {
|
||||
let Some(ref tab_id) = self.system_tab_id.clone() else { return };
|
||||
let Some(session) = (|| {
|
||||
let tab = self.tabs.iter().find(|t| t.id == *tab_id)?;
|
||||
if !tab.connected { return None; }
|
||||
tab.session.clone()
|
||||
})() else { return };
|
||||
if self.remote_sample_in_flight {
|
||||
return;
|
||||
}
|
||||
self.remote_sample_in_flight = true;
|
||||
let events = self.events_tx.clone();
|
||||
let tab_id = tab_id.clone();
|
||||
self.runtime.spawn(async move {
|
||||
match ssh::sample_remote_system(session).await {
|
||||
Ok(snapshot) => {
|
||||
let _ = events.send(BackendEvent::RemoteSystem { tab_id, snapshot });
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::RemoteSystemUnavailable {
|
||||
tab_id,
|
||||
reason: format!("remote metrics unavailable: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
|
||||
use gpui_component::Root;
|
||||
|
||||
use crate::session::config::ConfigStore;
|
||||
use crate::Ashell;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn sync_macos_launch_environment() {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
let Ok(output) = std::process::Command::new(&shell).args(["-l", "-c", "env -0"]).output() else {
|
||||
return;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in output.stdout.split(|b| *b == 0) {
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(eq) = entry.iter().position(|b| *b == b'=') else {
|
||||
continue;
|
||||
};
|
||||
let Ok(key) = std::str::from_utf8(&entry[..eq]) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = std::str::from_utf8(&entry[eq + 1..]) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let should_import = matches!(
|
||||
key,
|
||||
"PATH"
|
||||
| "MANPATH"
|
||||
| "INFOPATH"
|
||||
| "LANG"
|
||||
| "LC_ALL"
|
||||
| "LC_CTYPE"
|
||||
| "SHELL"
|
||||
| "HOME"
|
||||
| "HOMEBREW_PREFIX"
|
||||
| "HOMEBREW_CELLAR"
|
||||
| "HOMEBREW_REPOSITORY"
|
||||
) || key.starts_with("LC_");
|
||||
|
||||
if should_import {
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn sync_macos_launch_environment() {}
|
||||
|
||||
pub(crate) fn open_main_window(cx: &mut App) {
|
||||
let mut window_options = WindowOptions::default();
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Ok(img) = image::load_from_memory(include_bytes!("../../assets/icons/ashell.png")) {
|
||||
window_options.icon = Some(std::sync::Arc::new(img.into_rgba8()));
|
||||
}
|
||||
|
||||
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
if let Some(bounds) = config.window_bounds() {
|
||||
window_options.window_bounds = Some(match bounds {
|
||||
crate::session::config::SavedWindowBounds::Fullscreen {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Fullscreen(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
crate::session::config::SavedWindowBounds::Maximized {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Maximized(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
crate::session::config::SavedWindowBounds::Windowed {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Windowed(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
});
|
||||
} else if let Some(display) = cx.displays().first().cloned() {
|
||||
let display_bounds = display.bounds();
|
||||
let width = display_bounds.size.width * 0.8;
|
||||
let height = display_bounds.size.height * 0.9;
|
||||
|
||||
let x = display_bounds.origin.x + (display_bounds.size.width - width) / 2.0;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let y = display_bounds.origin.y;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let y = display_bounds.origin.y + (display_bounds.size.height - height) / 2.0;
|
||||
|
||||
window_options.window_bounds = Some(gpui::WindowBounds::Windowed(Bounds::new(
|
||||
point(x, y),
|
||||
size(width, height),
|
||||
)));
|
||||
}
|
||||
|
||||
cx.open_window(window_options, |window, cx| {
|
||||
window.activate_window();
|
||||
window.set_window_title("ashell");
|
||||
gpui_component::Theme::sync_system_appearance(Some(window), cx);
|
||||
let view = cx.new(|cx| Ashell::new(window, cx));
|
||||
|
||||
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
|
||||
let body_panels_clone = view.read(cx).body_panels.clone();
|
||||
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
|
||||
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
let current_bounds = window.window_bounds();
|
||||
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> = workspace_panels_clone
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.iter()
|
||||
.map(|s| s.into())
|
||||
.collect();
|
||||
let body_sizes: Vec<f32> = body_panels_clone
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.iter()
|
||||
.map(|s| s.into())
|
||||
.collect();
|
||||
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
|
||||
let _ = config.save();
|
||||
true
|
||||
});
|
||||
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
})
|
||||
.expect("failed to open window");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{Anchor, App, Context, IntoElement, SharedString, Window, px};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, IconName, Sizable as _, Theme, ThemeMode, ThemeRegistry,
|
||||
button::{Button, ButtonVariants as _},
|
||||
menu::{DropdownMenu as _, PopupMenuItem},
|
||||
};
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::Ashell;
|
||||
|
||||
pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
|
||||
include_str!("../../assets/themes/matrix.json"),
|
||||
include_str!("../../assets/themes/tokyonight.json"),
|
||||
include_str!("../../assets/themes/gruvbox.json"),
|
||||
include_str!("../../assets/themes/solarized.json"),
|
||||
];
|
||||
|
||||
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
|
||||
let regular =
|
||||
std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice());
|
||||
let bold = std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice());
|
||||
cx.text_system()
|
||||
.add_fonts(vec![regular, bold])
|
||||
.context("load Maple Mono NF CN fonts")?;
|
||||
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn load_embedded_themes(cx: &mut App) {
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for theme_json in EMBEDDED_THEME_JSONS {
|
||||
if let Err(err) = registry.load_themes_from_str(theme_json) {
|
||||
tracing::warn!("failed to load embedded theme: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
|
||||
theme.font_family = ui_font_family.into();
|
||||
theme.mono_font_family = ui_font_family.into();
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn switch_theme_mode(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.follow_system_theme = false;
|
||||
self.theme_mode = mode;
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
|
||||
self.persist_theme_preferences();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn apply_theme(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
|
||||
self.status = format!("theme not found: {name}").into();
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
if theme_config.mode.is_dark() {
|
||||
self.dark_theme_name = name.clone();
|
||||
} else {
|
||||
self.light_theme_name = name.clone();
|
||||
}
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.status = format!("theme: {name}").into();
|
||||
self.persist_theme_preferences();
|
||||
window.refresh();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_follow_system_theme(
|
||||
&mut self,
|
||||
follow: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.follow_system_theme = follow;
|
||||
if follow {
|
||||
self.status = "theme mode: system".into();
|
||||
} else {
|
||||
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
|
||||
}
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.persist_theme_preferences();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_display_language(&mut self, locale: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.config.set_locale(locale);
|
||||
let mut active_locale = 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);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save language preferences: {err:#}");
|
||||
}
|
||||
window.refresh();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn apply_theme_preferences(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let light_theme = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.get(&self.light_theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_light_theme().clone());
|
||||
let dark_theme = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.get(&self.dark_theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.light_theme = light_theme;
|
||||
theme.dark_theme = dark_theme;
|
||||
theme.font_size = px(self.ui_font_size);
|
||||
set_theme_font_names(theme, &self.ui_font_family);
|
||||
|
||||
if self.follow_system_theme {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
} else {
|
||||
Theme::change(self.theme_mode, Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_theme_preferences(&mut self) {
|
||||
let theme_mode_str = match self.theme_mode {
|
||||
ThemeMode::Light => "light",
|
||||
ThemeMode::Dark => "dark",
|
||||
};
|
||||
self.config.set_theme_preferences(
|
||||
self.follow_system_theme,
|
||||
theme_mode_str,
|
||||
self.light_theme_name.to_string(),
|
||||
self.dark_theme_name.to_string(),
|
||||
);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save theme preferences: {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn theme_dropdown(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
let themes = ThemeRegistry::global(cx)
|
||||
.sorted_themes()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let light_themes = themes
|
||||
.iter()
|
||||
.filter(|theme| !theme.mode.is_dark())
|
||||
.map(|theme| theme.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let dark_themes = themes
|
||||
.iter()
|
||||
.filter(|theme| theme.mode.is_dark())
|
||||
.map(|theme| theme.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let follow_system = self.follow_system_theme;
|
||||
let is_dark_mode = cx.theme().mode.is_dark();
|
||||
let light_theme_name = self.light_theme_name.clone();
|
||||
let dark_theme_name = self.dark_theme_name.clone();
|
||||
let icon = if follow_system {
|
||||
IconName::Sun
|
||||
} else if is_dark_mode {
|
||||
IconName::Moon
|
||||
} else {
|
||||
IconName::Sun
|
||||
};
|
||||
|
||||
Button::new("theme-dropdown")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(icon)
|
||||
.dropdown_menu_with_anchor(Anchor::BottomRight, move |mut menu, window, _| {
|
||||
menu = menu
|
||||
.min_w(220.)
|
||||
.item(
|
||||
PopupMenuItem::new(t!("follow_system"))
|
||||
.checked(follow_system)
|
||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
||||
this.set_follow_system_theme(true, window, cx)
|
||||
})),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new(t!("use_light_mode"))
|
||||
.checked(!follow_system && !is_dark_mode)
|
||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
||||
this.switch_theme_mode(ThemeMode::Light, window, cx)
|
||||
})),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new(t!("use_dark_mode"))
|
||||
.checked(!follow_system && is_dark_mode)
|
||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
||||
this.switch_theme_mode(ThemeMode::Dark, window, cx)
|
||||
})),
|
||||
)
|
||||
.separator()
|
||||
.label(t!("light_theme").to_string());
|
||||
|
||||
for theme_name in light_themes.clone() {
|
||||
let checked = theme_name == light_theme_name;
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(theme_name.clone())
|
||||
.checked(checked)
|
||||
.on_click(window.listener_for(&view, move |this, _, window, cx| {
|
||||
this.apply_theme(theme_name.clone(), window, cx)
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
menu = menu.separator();
|
||||
menu = menu.label(t!("dark_theme").to_string());
|
||||
|
||||
for theme_name in dark_themes.clone() {
|
||||
let checked = theme_name == dark_theme_name;
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(theme_name.clone())
|
||||
.checked(checked)
|
||||
.on_click(window.listener_for(&view, move |this, _, window, cx| {
|
||||
this.apply_theme(theme_name.clone(), window, cx)
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
menu
|
||||
})
|
||||
}
|
||||
}
|
||||
+2074
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
pub mod local;
|
||||
pub mod ssh;
|
||||
@@ -14,7 +14,7 @@ use russh::{
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
session::config::{AuthMethod, Session},
|
||||
system::{SystemSnapshot, remote_snapshot_from_kv},
|
||||
terminal::{BackendCommand, BackendEvent, BackendTx},
|
||||
};
|
||||
@@ -115,16 +115,16 @@ async fn run_ssh(
|
||||
tab_id: tab_id.clone(),
|
||||
});
|
||||
|
||||
let exit_reason;
|
||||
let mut is_graceful_close = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => {
|
||||
match command {
|
||||
Some(BackendCommand::Input(bytes)) => {
|
||||
if let Err(err) = channel.data(bytes.as_slice()).await {
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: tab_id.clone(),
|
||||
reason: format!("ssh write error: {err}"),
|
||||
});
|
||||
exit_reason = format!("ssh write error: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ async fn run_ssh(
|
||||
}
|
||||
Some(BackendCommand::Close) | None => {
|
||||
let _ = channel.eof().await;
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -145,7 +146,25 @@ async fn run_ssh(
|
||||
bytes: data.to_vec(),
|
||||
});
|
||||
}
|
||||
Some(ChannelMsg::Close) | None => break,
|
||||
Some(ChannelMsg::ExitStatus { exit_status: _ }) | Some(ChannelMsg::Eof) => {
|
||||
is_graceful_close = true;
|
||||
}
|
||||
Some(ChannelMsg::Close) => {
|
||||
if is_graceful_close {
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
} else {
|
||||
exit_reason = "ssh connection lost (abrupt close)".to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
if is_graceful_close {
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
} else {
|
||||
exit_reason = "ssh connection lost (network drop)".to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +176,7 @@ async fn run_ssh(
|
||||
.await;
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id,
|
||||
reason: "ssh session closed".into(),
|
||||
reason: exit_reason,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -169,6 +188,8 @@ async fn connect_and_authenticate(
|
||||
) -> Result<russh::client::Handle<ClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(10)),
|
||||
keepalive_max: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
@@ -365,7 +386,7 @@ EOF
|
||||
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -404,7 +425,7 @@ EOF
|
||||
echo "SWAP_USED=${swap_used:-0}"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(devfs|tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
|
||||
exit 0
|
||||
fi
|
||||
|
||||
+15
-4756
File diff suppressed because it is too large
Load Diff
@@ -125,6 +125,14 @@ pub struct ConfigFile {
|
||||
pub body_panels: Option<Vec<f32>>,
|
||||
#[serde(default)]
|
||||
pub transfers: Vec<crate::terminal::Transfer>,
|
||||
#[serde(default)]
|
||||
pub show_hidden_files: bool,
|
||||
#[serde(default = "default_monitoring_position")]
|
||||
pub monitoring_position: String,
|
||||
}
|
||||
|
||||
fn default_monitoring_position() -> String {
|
||||
"Sidebar".to_string()
|
||||
}
|
||||
|
||||
fn default_locale() -> String {
|
||||
@@ -228,6 +236,14 @@ impl ConfigStore {
|
||||
self.cache.locale = locale.to_string();
|
||||
}
|
||||
|
||||
pub fn monitoring_position(&self) -> &str {
|
||||
&self.cache.monitoring_position
|
||||
}
|
||||
|
||||
pub fn set_monitoring_position(&mut self, pos: &str) {
|
||||
self.cache.monitoring_position = pos.to_string();
|
||||
}
|
||||
|
||||
pub fn terminal_font_size(&self) -> f32 {
|
||||
if self.cache.terminal_font_size <= 0.0 {
|
||||
default_terminal_font_size()
|
||||
@@ -257,6 +273,7 @@ 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()
|
||||
}
|
||||
@@ -331,6 +348,14 @@ impl ConfigStore {
|
||||
self.cache.terminal_font_family = family.to_string();
|
||||
}
|
||||
|
||||
pub fn show_hidden_files(&self) -> bool {
|
||||
self.cache.show_hidden_files
|
||||
}
|
||||
|
||||
pub fn set_show_hidden_files(&mut self, val: bool) {
|
||||
self.cache.show_hidden_files = val;
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Session> {
|
||||
self.cache.sessions.iter().find(|s| s.id == id)
|
||||
}
|
||||
+1186
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
pub mod ops;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
@@ -27,7 +29,7 @@ use zip::read::ZipArchive;
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
session::config::{AuthMethod, Session},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
@@ -496,7 +498,7 @@ async fn run_sftp(
|
||||
}
|
||||
SftpCommand::EditFile { remote_path } => {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let config = crate::config::ConfigStore::load().unwrap();
|
||||
let config = crate::session::config::ConfigStore::load().unwrap();
|
||||
let tmp_dir = config.tmp_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
let base = base_name(&remote_path);
|
||||
let local_path = tmp_dir.join(format!("{}-{}", id, base));
|
||||
@@ -1314,6 +1316,7 @@ async fn upload_paths_impl(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create directories sequentially first
|
||||
for dir in dirs_to_create {
|
||||
create_remote_dir_all(sftp, &dir).await?;
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
use gpui::{Context, PathPromptOptions, Pixels, Point, Window};
|
||||
|
||||
use crate::{
|
||||
Ashell, SftpContextMenuState,
|
||||
sftp::{RemoteEntry, SftpHandle},
|
||||
terminal,
|
||||
};
|
||||
|
||||
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
|
||||
let lower = filename.to_lowercase();
|
||||
let ext = std::path::Path::new(&lower).extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let known_exts = ["txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", "css", "md", "toml", "csv", "log", "cfg"];
|
||||
if known_exts.contains(&ext) {
|
||||
return true;
|
||||
}
|
||||
let known_names = ["dockerfile", "makefile", ".gitignore", ".env"];
|
||||
if known_names.contains(&lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
|
||||
self.active_group.as_ref()
|
||||
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
|
||||
.and_then(|g| g.sftp.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
|
||||
let active_id = self.active_group.clone()?;
|
||||
self.tab_groups.iter_mut().find(|g| g.id == active_id)
|
||||
.and_then(|g| g.sftp.as_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
|
||||
self.active_group.as_ref()
|
||||
.and_then(|id| self.sftp_handles.get(id))
|
||||
}
|
||||
|
||||
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
handle.list_dir(path.clone());
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.current_path = path;
|
||||
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
|
||||
if entry.is_dir {
|
||||
self.navigate_sftp(entry.full_path, cx);
|
||||
return;
|
||||
}
|
||||
self.mark_sftp_entry_selected(&entry.full_path, cx);
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
if !sftp.selected_entries.remove(&entry.full_path) {
|
||||
sftp.selected_entries.insert(entry.full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_sftp_entry_selected(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.selected_path = Some(path.to_string());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn sftp_parent_path(path: &str) -> String {
|
||||
if path == "/" {
|
||||
return "/".to_string();
|
||||
}
|
||||
path.trim_end_matches('/')
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| {
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
parent.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_sftp(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) {
|
||||
self.navigate_sftp(path, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sync_sftp_path_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(path) = self.pending_sftp_path_sync.take() else {
|
||||
return;
|
||||
};
|
||||
self.sftp_path_input.update(cx, |state, cx| {
|
||||
state.set_value(path, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_sftp_context_menu(
|
||||
&mut self,
|
||||
remote_path: String,
|
||||
is_dir: bool,
|
||||
position: Point<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.sftp_context_menu = Some(SftpContextMenuState {
|
||||
remote_path,
|
||||
is_dir,
|
||||
position,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss_sftp_context_menu(&mut self, cx: &mut Context<Self>) {
|
||||
if self.sftp_context_menu.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_sftp_context_download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(menu) = self.sftp_context_menu.take() else {
|
||||
return;
|
||||
};
|
||||
self.download_sftp_entry(menu.remote_path, window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(menu) = self.sftp_context_menu.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
handle.edit_file(menu.remote_path);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn download_sftp_entry(
|
||||
&mut self,
|
||||
remote_path: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Download Folder".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(folder) = paths.pop() {
|
||||
handle.download(remote_path, folder.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("download picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_files(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let remote_dir = self
|
||||
.active_sftp()
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some("Select File to Upload".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(file) = paths.pop() {
|
||||
handle.upload_paths(vec![file.to_string_lossy().to_string()], remote_dir);
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("upload picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let remote_dir = self
|
||||
.active_sftp()
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Folder to Upload".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(folder) = paths.pop() {
|
||||
handle.upload_paths(vec![folder.to_string_lossy().to_string()], remote_dir);
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("upload picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_sftp_entry(&mut self, path: String, checked: bool, cx: &mut Context<Self>) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
if checked {
|
||||
sftp.selected_entries.insert(path);
|
||||
} else {
|
||||
sftp.selected_entries.remove(&path);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
if checked {
|
||||
let paths: Vec<String> = sftp.entries.iter().map(|e| e.full_path.clone()).collect();
|
||||
for path in paths {
|
||||
sftp.selected_entries.insert(path);
|
||||
}
|
||||
} else {
|
||||
sftp.selected_entries.clear();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(sftp) = self.active_sftp() else {
|
||||
return;
|
||||
};
|
||||
let selected: Vec<String> = sftp.selected_entries.iter().cloned().collect();
|
||||
if selected.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Download Folder".into()),
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
|
||||
if let Some(folder) = paths.pop() {
|
||||
let local_dir = folder.to_string_lossy().to_string();
|
||||
for remote in selected {
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
|
||||
remote,
|
||||
local_dir: local_dir.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
if let Some(sftp_mut) = this.active_sftp_mut() {
|
||||
sftp_mut.selected_entries.clear();
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, _cx: &mut Context<Self>) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(sftp) = self.active_sftp() {
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
|
||||
locals: paths,
|
||||
remote_dir: sftp.current_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::OsStr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use sysinfo::{Disks, Networks, System};
|
||||
|
||||
/// Known virtual/ram filesystems to exclude from disk monitoring.
|
||||
fn is_real_filesystem(fs: &OsStr) -> bool {
|
||||
!matches!(
|
||||
fs.to_str(),
|
||||
Some("tmpfs" | "devtmpfs" | "ramfs" | "overlay" | "aufs")
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiskSample {
|
||||
pub mount: String,
|
||||
@@ -22,7 +31,10 @@ pub struct SystemSnapshot {
|
||||
pub swap_detail: String,
|
||||
pub net_rx: String,
|
||||
pub net_tx: String,
|
||||
pub net_rx_rate: u64,
|
||||
pub net_tx_rate: u64,
|
||||
pub disks: Vec<DiskSample>,
|
||||
pub total_swap: u64,
|
||||
}
|
||||
|
||||
pub struct SystemSampler {
|
||||
@@ -82,17 +94,25 @@ impl SystemSampler {
|
||||
self.last_tx_total = tx_total;
|
||||
self.last_instant = now;
|
||||
|
||||
let disks = self
|
||||
let mut disks: Vec<DiskSample> = self
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|disk| disk.total_space() > 0)
|
||||
.take(4)
|
||||
.filter(|disk| disk.total_space() > 0 && is_real_filesystem(disk.file_system()))
|
||||
.map(|disk| DiskSample {
|
||||
mount: disk.mount_point().to_string_lossy().to_string(),
|
||||
available_bytes: disk.available_space(),
|
||||
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)
|
||||
});
|
||||
|
||||
SystemSnapshot {
|
||||
cpu_percent,
|
||||
@@ -102,7 +122,10 @@ impl SystemSampler {
|
||||
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)),
|
||||
net_rx_rate: rx_rate,
|
||||
net_tx_rate: tx_rate,
|
||||
disks,
|
||||
total_swap: swap_total,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +199,20 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||
let rx_rate = parse_u64(&kv, "NET_RX");
|
||||
let tx_rate = parse_u64(&kv, "NET_TX");
|
||||
|
||||
// Safety filter: exclude entries with zero/negligible total size
|
||||
// (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)
|
||||
});
|
||||
|
||||
Ok(SystemSnapshot {
|
||||
cpu_percent: cpu_percent.clamp(0.0, 1.0),
|
||||
mem_percent: ratio(mem_used, mem_total),
|
||||
@@ -184,7 +221,10 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||
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)),
|
||||
net_rx_rate: rx_rate,
|
||||
net_tx_rate: tx_rate,
|
||||
disks,
|
||||
total_swap: swap_total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,6 +144,8 @@ pub struct PrepaintState {
|
||||
struct TerminalInputHandler {
|
||||
view: Entity<Ashell>,
|
||||
element_bounds: Bounds<Pixels>,
|
||||
cell_width: f32,
|
||||
line_height: f32,
|
||||
}
|
||||
|
||||
impl InputHandler for TerminalInputHandler {
|
||||
@@ -219,7 +221,7 @@ impl InputHandler for TerminalInputHandler {
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
self.view
|
||||
.read(cx)
|
||||
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds)
|
||||
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds, self.cell_width, self.line_height)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
@@ -492,6 +494,8 @@ impl Element for TerminalElement {
|
||||
TerminalInputHandler {
|
||||
view: self.view.clone(),
|
||||
element_bounds: prepaint.bounds,
|
||||
cell_width: prepaint.metrics.cell_width.as_f32(),
|
||||
line_height: prepaint.metrics.line_height.as_f32(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
@@ -0,0 +1,416 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use alacritty_terminal::index::Side;
|
||||
use alacritty_terminal::selection::SelectionType;
|
||||
use gpui::{
|
||||
ClipboardItem, Context, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Ashell,
|
||||
terminal::{BackendCommand, encode_key},
|
||||
TerminalBacktabKey, TerminalTabKey,
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn on_terminal_key_down(
|
||||
&mut self,
|
||||
event: &KeyDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Pane navigation: Alt + h/j/k/l
|
||||
if event.keystroke.modifiers.alt
|
||||
&& !event.keystroke.modifiers.shift
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
match event.keystroke.key.to_ascii_lowercase().as_str() {
|
||||
"h" => self.focus_adjacent_pane("left"),
|
||||
"j" => self.focus_adjacent_pane("down"),
|
||||
"k" => self.focus_adjacent_pane("up"),
|
||||
"l" => self.focus_adjacent_pane("right"),
|
||||
"q" => {
|
||||
if let Some(active_id) = self.active_tab.clone() {
|
||||
self.close_tab(active_id, cx);
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pane split: Shift+Alt + h/j/k/l
|
||||
if event.keystroke.modifiers.shift
|
||||
&& event.keystroke.modifiers.alt
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
let direction = match event.keystroke.key.to_ascii_lowercase().as_str() {
|
||||
"h" => Some("left"),
|
||||
"j" => Some("down"),
|
||||
"k" => Some("up"),
|
||||
"l" => Some("right"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(dir) = direction {
|
||||
self.split_current_pane(dir, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key == "," {
|
||||
self.show_settings_dialog(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
if event.keystroke.modifiers.shift
|
||||
&& event.keystroke.modifiers.secondary()
|
||||
&& event.keystroke.key == "o"
|
||||
{
|
||||
self.show_selector_dialog(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("c") {
|
||||
if let Some(text) = self.active_terminal_selection_text() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("v") {
|
||||
if let Some(clipboard) = cx.read_from_clipboard() {
|
||||
if let Some(text) = clipboard.text() {
|
||||
self.paste_into_terminal(&text, window, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.prefer_character_input {
|
||||
if let Some(text) = event.keystroke.key_char.as_deref() {
|
||||
if !text.is_empty()
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.function
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
self.send_terminal_input(text.as_bytes().to_vec(), window, cx);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
|
||||
if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) {
|
||||
tab.backend.send(BackendCommand::Input(bytes));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_tab_action(
|
||||
&mut self,
|
||||
_: &TerminalTabKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.send_terminal_input(vec![b'\t'], window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_backtab_action(
|
||||
&mut self,
|
||||
_: &TerminalBacktabKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.send_terminal_input(b"\x1b[Z".to_vec(), window, cx);
|
||||
}
|
||||
|
||||
fn send_terminal_input(&mut self, bytes: Vec<u8>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
|
||||
tab.clear_selection();
|
||||
tab.backend.send(BackendCommand::Input(bytes));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn active_terminal_selection_text(&self) -> Option<String> {
|
||||
let active_id = self.active_tab.as_ref()?;
|
||||
self.tabs
|
||||
.iter()
|
||||
.find(|tab| &tab.id == active_id)
|
||||
.and_then(|tab| tab.selection_text())
|
||||
}
|
||||
|
||||
fn paste_into_terminal(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
tab.paste_text(text);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_accepts_text_input(&self) -> bool {
|
||||
self.active_tab.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_marked_text_range(&self) -> Option<Range<usize>> {
|
||||
self.terminal_marked_text
|
||||
.as_ref()
|
||||
.map(|text| 0..text.encode_utf16().count())
|
||||
}
|
||||
|
||||
pub(crate) fn set_terminal_marked_text(
|
||||
&mut self,
|
||||
text: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.terminal_marked_text = if text.is_empty() { None } else { Some(text) };
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_terminal_marked_text(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.terminal_marked_text.take().is_some() {
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_terminal_ime_text(
|
||||
&mut self,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
self.terminal_marked_text = None;
|
||||
tab.backend
|
||||
.send(BackendCommand::Input(text.as_bytes().to_vec()));
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_right_click(
|
||||
&mut self,
|
||||
_event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.config.right_click_copy_paste() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut handled = false;
|
||||
if let Some(text) = self.active_terminal_selection_text() {
|
||||
if !text.is_empty() {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
||||
|
||||
let active_id = self.active_tab.clone();
|
||||
if let Some(active_id) = active_id {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.clear_selection();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !handled {
|
||||
if let Some(clipboard_item) = cx.read_from_clipboard() {
|
||||
if let Some(text) = clipboard_item.text() {
|
||||
if !text.is_empty() {
|
||||
self.paste_into_terminal(&text, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin_terminal_selection(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
|
||||
let click_count = event.click_count.max(1);
|
||||
let selection_type = match click_count {
|
||||
1 => SelectionType::Simple,
|
||||
2 => SelectionType::Semantic,
|
||||
3 => SelectionType::Lines,
|
||||
_ => SelectionType::Simple,
|
||||
};
|
||||
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
|
||||
return;
|
||||
};
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.begin_selection(row, col, side, selection_type);
|
||||
self.terminal_selecting = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_mouse_move(
|
||||
&mut self,
|
||||
event: &MouseMoveEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Handle split drag
|
||||
if self.dragging_splitter.is_some() {
|
||||
if event.pressed_button == Some(MouseButton::Left) {
|
||||
self.on_split_drag_move(event, window, cx);
|
||||
cx.notify();
|
||||
} else {
|
||||
self.end_drag_split();
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
|
||||
return;
|
||||
};
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.update_selection(row, col, side);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_mouse_up(
|
||||
&mut self,
|
||||
_event: &MouseUpEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.dragging_splitter.is_some() {
|
||||
self.end_drag_split();
|
||||
}
|
||||
self.terminal_selecting = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn terminal_grid_point_and_side(
|
||||
&self,
|
||||
position: Point<Pixels>,
|
||||
) -> Option<(usize, usize, Side)> {
|
||||
let active_id = self.active_tab.as_ref()?;
|
||||
let bounds = self.terminal_bounds.get(active_id)?;
|
||||
if !bounds.contains(&position) {
|
||||
// Try other pane bounds
|
||||
for (_, b) in &self.terminal_bounds {
|
||||
if b.contains(&position) {
|
||||
// Found a different pane - focus it
|
||||
// (this path is for click-to-focus; handled via focus_terminal)
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let local_x = (position.x - bounds.origin.x).max(px(0.));
|
||||
let local_y = (position.y - bounds.origin.y).max(px(0.));
|
||||
let cell_width = px(self.terminal_cell_width());
|
||||
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 cell_offset_x = px(local_x.as_f32() % cell_width.as_f32());
|
||||
let side = if cell_offset_x >= (cell_width / 2.) {
|
||||
Side::Right
|
||||
} else {
|
||||
Side::Left
|
||||
};
|
||||
Some((row, col, side))
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_scroll(
|
||||
&mut self,
|
||||
event: &ScrollWheelEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let delta_lines = match event.delta {
|
||||
ScrollDelta::Lines(point) => point.y.round() as i32,
|
||||
ScrollDelta::Pixels(point) => {
|
||||
(point.y.as_f32() / self.terminal_line_height()).round() as i32
|
||||
}
|
||||
};
|
||||
if delta_lines == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.scroll_history(delta_lines);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod element;
|
||||
pub mod input;
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use alacritty_terminal::{
|
||||
@@ -10,7 +13,7 @@ use alacritty_terminal::{
|
||||
};
|
||||
use gpui::Keystroke;
|
||||
|
||||
use crate::config::Session;
|
||||
use crate::session::config::Session;
|
||||
use crate::sftp::{PreviewData, RemoteEntry};
|
||||
use crate::system::SystemSnapshot;
|
||||
|
||||
@@ -113,7 +116,6 @@ pub struct TerminalTab {
|
||||
pub status: String,
|
||||
pub connected: bool,
|
||||
pub session: Option<Session>,
|
||||
pub sftp: Option<SftpUiState>,
|
||||
processor: Processor,
|
||||
term: Term<TerminalListener>,
|
||||
cols: u16,
|
||||
@@ -202,15 +204,6 @@ impl TerminalTab {
|
||||
);
|
||||
tab.session = Some(session.clone());
|
||||
tab.connected = false;
|
||||
tab.sftp = Some(SftpUiState {
|
||||
current_path: "/".into(),
|
||||
status: t!("sftp_connecting").to_string(),
|
||||
entries: Vec::new(),
|
||||
selected_path: None,
|
||||
preview: None,
|
||||
selected_entries: std::collections::HashSet::new(),
|
||||
home_dir: "/".into(),
|
||||
});
|
||||
tab
|
||||
}
|
||||
|
||||
@@ -229,7 +222,6 @@ impl TerminalTab {
|
||||
status,
|
||||
connected: matches!(kind, TabKind::Local),
|
||||
session: None,
|
||||
sftp: None,
|
||||
processor: Processor::new(),
|
||||
term: new_term(100, 30, backend.clone(), id, events),
|
||||
cols: 100,
|
||||
@@ -443,7 +435,6 @@ impl EventListener for TerminalListener {
|
||||
}
|
||||
}
|
||||
|
||||
use rust_i18n::t;
|
||||
fn new_term(
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
Reference in New Issue
Block a user