mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 00:00:59 +00:00
4393 lines
198 KiB
Rust
4393 lines
198 KiB
Rust
#![windows_subsystem = "windows"]
|
|
|
|
use std::{
|
|
cell::{Cell, RefCell},
|
|
collections::HashMap,
|
|
ops::Range,
|
|
process::Command,
|
|
rc::Rc,
|
|
sync::mpsc,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use alacritty_terminal::index::Side;
|
|
use alacritty_terminal::selection::SelectionType;
|
|
use anyhow::{Context as _, Result};
|
|
use gpui::{
|
|
Anchor, App, AppContext as _, Bounds, ClipboardItem, Context, ElementId, Entity, FocusHandle,
|
|
Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement, KeyBinding,
|
|
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
|
PathPromptOptions, Pixels, Point, QuitMode, Render, ScrollDelta, ScrollWheelEvent,
|
|
SharedString, Size, StatefulInteractiveElement, Styled as _, UniformListScrollHandle, Window, WindowOptions, div, point,
|
|
prelude::FluentBuilder as _, px, rems, size, uniform_list,
|
|
};
|
|
use gpui_component::{
|
|
ActiveTheme as _, Disableable, ElementExt, IconName, Root, Sizable as _, Theme, ThemeMode,
|
|
ThemeRegistry, WindowExt as _,
|
|
button::{Button, ButtonVariants as _},
|
|
checkbox::Checkbox,
|
|
switch::Switch,
|
|
dialog::Dialog,
|
|
h_flex,
|
|
input::{Input, InputEvent, InputState},
|
|
menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenuItem},
|
|
progress::Progress,
|
|
resizable::{ResizableState, h_resizable, resizable_panel, v_resizable},
|
|
scroll::{ScrollableElement as _, Scrollbar, ScrollbarHandle, ScrollbarShow},
|
|
tab::{Tab, TabBar},
|
|
v_flex,
|
|
};
|
|
use gpui_component_assets::Assets;
|
|
use rust_i18n::t;
|
|
use tokio::runtime::Runtime;
|
|
use uuid::Uuid;
|
|
|
|
mod config;
|
|
mod local_terminal;
|
|
mod sftp;
|
|
mod ssh_terminal;
|
|
mod system;
|
|
mod terminal;
|
|
mod terminal_element;
|
|
|
|
use config::{AuthMethod, ConfigStore, Session};
|
|
use sftp::{RemoteEntry, SftpHandle, format_mtime};
|
|
use system::{DiskSample, SystemSampler, SystemSnapshot, format_bytes};
|
|
use terminal::{BackendCommand, BackendEvent, TabKind, TerminalTab, encode_key};
|
|
use terminal_element::TerminalElement;
|
|
|
|
rust_i18n::i18n!("locales", fallback = "en");
|
|
|
|
const DEFAULT_COLS: u16 = 100;
|
|
const DEFAULT_ROWS: u16 = 30;
|
|
|
|
const SIDEBAR_WIDTH: f32 = 306.0;
|
|
const TAB_BAR_HEIGHT: f32 = 52.0;
|
|
const TERMINAL_PADDING_X: f32 = 32.0;
|
|
const TERMINAL_PADDING_Y: f32 = 32.0;
|
|
const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
|
|
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"),
|
|
];
|
|
|
|
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct TerminalScrollbarState {
|
|
line_height: Pixels,
|
|
total_lines: usize,
|
|
viewport_lines: usize,
|
|
display_offset: usize,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct TerminalScrollbarHandle {
|
|
state: Rc<RefCell<Option<TerminalScrollbarState>>>,
|
|
future_display_offset: Rc<Cell<Option<usize>>>,
|
|
}
|
|
|
|
impl TerminalScrollbarHandle {
|
|
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 Some(state) = *self.state.borrow() 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 Some(state) = *self.state.borrow() 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 Some(state) = *self.state.borrow() else {
|
|
return size(px(0.), px(0.));
|
|
};
|
|
size(
|
|
px(0.),
|
|
state.total_lines.max(state.viewport_lines) as f32 * state.line_height,
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
struct Ashell {
|
|
focus_handle: FocusHandle,
|
|
selector_focus_handle: FocusHandle,
|
|
host_input: Entity<InputState>,
|
|
session_name_input: Entity<InputState>,
|
|
port_input: Entity<InputState>,
|
|
user_input: Entity<InputState>,
|
|
password_input: Entity<InputState>,
|
|
key_path_input: Entity<InputState>,
|
|
key_inline_input: Entity<InputState>,
|
|
sftp_path_input: Entity<InputState>,
|
|
ssh_auth_method: AuthMethod,
|
|
editing_session_id: Option<String>,
|
|
follow_system_theme: bool,
|
|
theme_mode: ThemeMode,
|
|
light_theme_name: SharedString,
|
|
dark_theme_name: SharedString,
|
|
ui_font_size: f32,
|
|
terminal_font_size: f32,
|
|
ui_font_family: SharedString,
|
|
terminal_font_family: SharedString,
|
|
tabs: Vec<TerminalTab>,
|
|
sftp_handles: HashMap<String, SftpHandle>,
|
|
active_tab: Option<String>,
|
|
selector_selection: usize,
|
|
workspace_panels: Entity<ResizableState>,
|
|
body_panels: Entity<ResizableState>,
|
|
terminal_scrollbar: TerminalScrollbarHandle,
|
|
remote_files_scroll_handle: UniformListScrollHandle,
|
|
connection_progress: Option<ConnectionProgress>,
|
|
pending_sftp_path_sync: Option<String>,
|
|
sftp_context_menu: Option<SftpContextMenuState>,
|
|
show_hidden_files: bool,
|
|
transfers: Vec<crate::terminal::Transfer>,
|
|
show_transfers_dialog: bool,
|
|
system_status: Option<SharedString>,
|
|
terminal_bounds: Option<Bounds<Pixels>>,
|
|
terminal_selecting: bool,
|
|
terminal_marked_text: Option<String>,
|
|
status: SharedString,
|
|
config: ConfigStore,
|
|
system_sampler: SystemSampler,
|
|
system: SystemSnapshot,
|
|
last_system_sample: Instant,
|
|
last_theme_sync: Instant,
|
|
remote_sample_in_flight: bool,
|
|
runtime: Runtime,
|
|
events_rx: mpsc::Receiver<BackendEvent>,
|
|
events_tx: mpsc::Sender<BackendEvent>,
|
|
_subscriptions: Vec<gpui::Subscription>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum SelectorEntry {
|
|
Local,
|
|
NewSsh,
|
|
Saved(String),
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ConnectionProgress {
|
|
tab_id: String,
|
|
title: SharedString,
|
|
lines: Vec<SharedString>,
|
|
failed: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SftpContextMenuState {
|
|
remote_path: String,
|
|
is_dir: bool,
|
|
position: Point<Pixels>,
|
|
}
|
|
|
|
impl Ashell {
|
|
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 _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),
|
|
];
|
|
|
|
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(),
|
|
sftp_handles: HashMap::new(),
|
|
active_tab: None,
|
|
selector_selection: 0,
|
|
workspace_panels,
|
|
body_panels,
|
|
terminal_scrollbar: TerminalScrollbarHandle::default(),
|
|
remote_files_scroll_handle: UniformListScrollHandle::new(),
|
|
connection_progress: None,
|
|
pending_sftp_path_sync: Some("/".into()),
|
|
sftp_context_menu: None,
|
|
show_hidden_files: false,
|
|
transfers: config.transfers(),
|
|
show_transfers_dialog: false,
|
|
system_status: None,
|
|
terminal_bounds: None,
|
|
terminal_selecting: false,
|
|
terminal_marked_text: None,
|
|
status: "ready".into(),
|
|
config,
|
|
system_sampler,
|
|
system,
|
|
last_system_sample: Instant::now(),
|
|
last_theme_sync: Instant::now(),
|
|
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
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn start_event_pump(&self, cx: &mut Context<Self>) {
|
|
cx.spawn(async move |this, cx| {
|
|
loop {
|
|
cx.background_executor()
|
|
.timer(Duration::from_millis(16))
|
|
.await;
|
|
if this
|
|
.update(cx, |this, cx| {
|
|
this.drain_backend_events();
|
|
this.sample_system_if_due();
|
|
this.sync_theme_if_due(cx);
|
|
cx.notify();
|
|
})
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn drain_backend_events(&mut self) {
|
|
let mut transfers_changed = false;
|
|
while let Ok(event) = self.events_rx.try_recv() {
|
|
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());
|
|
}
|
|
}
|
|
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.request_active_system_snapshot();
|
|
if self
|
|
.connection_progress
|
|
.as_ref()
|
|
.is_some_and(|progress| progress.tab_id == tab_id)
|
|
{
|
|
self.connection_progress = None;
|
|
}
|
|
}
|
|
BackendEvent::SftpEntries {
|
|
tab_id,
|
|
path,
|
|
entries,
|
|
} => {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
if let Some(sftp) = tab.sftp.as_mut() {
|
|
sftp.current_path = path;
|
|
sftp.entries = entries;
|
|
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
|
|
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
BackendEvent::SftpPreview { tab_id, preview } => {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
if let Some(sftp) = tab.sftp.as_mut() {
|
|
sftp.selected_path = Some(preview.path.clone());
|
|
sftp.preview = Some(preview);
|
|
}
|
|
}
|
|
}
|
|
BackendEvent::SftpStatus { tab_id, text } => {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
if let Some(sftp) = tab.sftp.as_mut() {
|
|
sftp.status = text.clone();
|
|
}
|
|
}
|
|
self.status = text.into();
|
|
}
|
|
BackendEvent::RemoteSystem { tab_id, snapshot } => {
|
|
self.remote_sample_in_flight = false;
|
|
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
|
|
self.system_status = None;
|
|
self.system = snapshot;
|
|
}
|
|
}
|
|
BackendEvent::RemoteSystemUnavailable { tab_id, reason } => {
|
|
self.remote_sample_in_flight = false;
|
|
if self.active_tab.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.active_tab.as_deref() == Some(tab_id.as_str()) {
|
|
self.system_status = Some(reason.clone().into());
|
|
}
|
|
let is_graceful_exit = reason == "Terminal process exited" || reason == "SSH session exited";
|
|
if let Some(progress) = self.connection_progress.as_mut() {
|
|
if progress.tab_id == tab_id {
|
|
progress.lines.push(reason.clone().into());
|
|
let _ = session_label;
|
|
let _ = tab_title;
|
|
progress.title = t!("connection_failed").into();
|
|
progress.failed = true;
|
|
}
|
|
} else if let Some(_) = session_label {
|
|
if !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::TerminalTitleChanged { tab_id, title } => {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
|
if let Some(sftp) = &tab.sftp {
|
|
let potential_path = if let Some((_, p)) = title.rsplit_once(':') {
|
|
p.trim()
|
|
} else {
|
|
title.trim()
|
|
};
|
|
|
|
if potential_path.starts_with('/') || potential_path.starts_with('~') {
|
|
let path_to_sync = potential_path.to_string();
|
|
if path_to_sync != sftp.current_path {
|
|
if let Some(handle) = self.sftp_handles.get(&tab_id) {
|
|
handle.list_dir(path_to_sync);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
},
|
|
);
|
|
if self.transfers.len() > 50 {
|
|
self.transfers.truncate(50);
|
|
}
|
|
self.show_transfers_dialog = true;
|
|
transfers_changed = true;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if transfers_changed {
|
|
self.config.set_transfers(self.transfers.clone());
|
|
}
|
|
}
|
|
|
|
fn sample_system_if_due(&mut self) {
|
|
if self.last_system_sample.elapsed() >= SystemSampler::interval() {
|
|
self.request_active_system_snapshot();
|
|
}
|
|
}
|
|
|
|
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) {
|
|
Theme::sync_system_appearance(None, cx);
|
|
self.last_theme_sync = Instant::now();
|
|
}
|
|
}
|
|
|
|
fn request_active_system_snapshot(&mut self) {
|
|
if let Some((tab_id, session)) = self.active_ssh_session() {
|
|
if self.remote_sample_in_flight {
|
|
return;
|
|
}
|
|
self.remote_sample_in_flight = true;
|
|
let events = self.events_tx.clone();
|
|
self.runtime.spawn(async move {
|
|
match ssh_terminal::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:#}"),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
self.system = self.system_sampler.sample();
|
|
self.system_status = None;
|
|
self.remote_sample_in_flight = false;
|
|
}
|
|
self.last_system_sample = Instant::now();
|
|
}
|
|
|
|
fn open_local(&mut self, cx: &mut Context<Self>) {
|
|
let id = Uuid::new_v4().to_string();
|
|
match local_terminal::spawn_local_terminal(
|
|
id.clone(),
|
|
DEFAULT_COLS,
|
|
DEFAULT_ROWS,
|
|
self.events_tx.clone(),
|
|
) {
|
|
Ok(backend) => {
|
|
let title = if cfg!(windows) { "PowerShell" } else { "Local" }.to_string();
|
|
let mut tab =
|
|
TerminalTab::new_local(id.clone(), title, backend, self.events_tx.clone());
|
|
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
|
|
self.tabs.push(tab);
|
|
self.active_tab = Some(id);
|
|
self.status = "local terminal opened".into();
|
|
}
|
|
Err(err) => {
|
|
self.status = format!("failed to open local terminal: {err:#}").into();
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let session_name = self.session_name_input.read(cx).value().trim().to_string();
|
|
let host = self.host_input.read(cx).value().trim().to_string();
|
|
let port = self
|
|
.port_input
|
|
.read(cx)
|
|
.value()
|
|
.trim()
|
|
.parse::<u16>()
|
|
.unwrap_or(22);
|
|
let user = self.user_input.read(cx).value().trim().to_string();
|
|
let password = self.password_input.read(cx).value().to_string();
|
|
let key_path = self.key_path_input.read(cx).value().trim().to_string();
|
|
let key_inline = self.key_inline_input.read(cx).value().to_string();
|
|
|
|
if host.is_empty() || user.is_empty() {
|
|
self.status = t!("host_and_user_required").into();
|
|
cx.notify();
|
|
return;
|
|
}
|
|
|
|
let name = if session_name.is_empty() {
|
|
host.clone()
|
|
} else {
|
|
session_name
|
|
};
|
|
let existing_id = self.editing_session_id.clone();
|
|
let existing_last_used = existing_id
|
|
.as_deref()
|
|
.and_then(|id| self.config.get(id))
|
|
.and_then(|session| session.last_used.clone());
|
|
|
|
let mut session = match self.ssh_auth_method {
|
|
AuthMethod::Password => Session::password(host, port, user, password),
|
|
AuthMethod::Key => {
|
|
if key_path.is_empty() && key_inline.trim().is_empty() {
|
|
self.status = "private key path or content is required".into();
|
|
cx.notify();
|
|
return;
|
|
}
|
|
Session::key(host, port, user, key_path, key_inline)
|
|
}
|
|
};
|
|
session.name = name;
|
|
if let Some(id) = existing_id {
|
|
session.id = id;
|
|
}
|
|
session.last_used = existing_last_used;
|
|
self.config.upsert(session.clone());
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save config: {err:#}");
|
|
}
|
|
|
|
self.open_ssh_session(session, cx);
|
|
self.editing_session_id = None;
|
|
window.close_dialog(cx);
|
|
}
|
|
|
|
fn set_input_value(
|
|
input: &Entity<InputState>,
|
|
value: impl Into<SharedString>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
input.update(cx, |state, cx| state.set_value(value, window, cx));
|
|
}
|
|
|
|
fn reset_ssh_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.editing_session_id = None;
|
|
self.ssh_auth_method = AuthMethod::Password;
|
|
Self::set_input_value(&self.session_name_input, "", window, cx);
|
|
Self::set_input_value(&self.host_input, "", window, cx);
|
|
Self::set_input_value(&self.port_input, "22", window, cx);
|
|
Self::set_input_value(&self.user_input, "root", window, cx);
|
|
Self::set_input_value(&self.password_input, "", window, cx);
|
|
Self::set_input_value(&self.key_path_input, "", window, cx);
|
|
Self::set_input_value(&self.key_inline_input, "", window, cx);
|
|
}
|
|
|
|
fn load_session_into_form(
|
|
&mut self,
|
|
session: &Session,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.editing_session_id = Some(session.id.clone());
|
|
self.ssh_auth_method = session.auth;
|
|
Self::set_input_value(&self.session_name_input, session.name.clone(), window, cx);
|
|
Self::set_input_value(&self.host_input, session.host.clone(), window, cx);
|
|
Self::set_input_value(&self.port_input, session.port.to_string(), window, cx);
|
|
Self::set_input_value(&self.user_input, session.user.clone(), window, cx);
|
|
Self::set_input_value(&self.password_input, session.password.clone(), window, cx);
|
|
Self::set_input_value(
|
|
&self.key_path_input,
|
|
session.private_key_path.clone(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(
|
|
&self.key_inline_input,
|
|
session.private_key_inline.clone(),
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn open_new_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.reset_ssh_form(window, cx);
|
|
self.show_ssh_dialog(window, cx);
|
|
}
|
|
|
|
fn edit_saved_session(
|
|
&mut self,
|
|
session_id: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(session) = self.config.get(&session_id).cloned() else {
|
|
self.status = "saved session not found".into();
|
|
cx.notify();
|
|
return;
|
|
};
|
|
self.load_session_into_form(&session, window, cx);
|
|
self.show_ssh_dialog(window, cx);
|
|
}
|
|
|
|
fn terminal_cell_width(&self) -> f32 {
|
|
(self.terminal_font_size * 0.646).max(6.0)
|
|
}
|
|
|
|
fn terminal_line_height(&self) -> f32 {
|
|
(self.terminal_font_size * 1.385).max(self.terminal_font_size + 2.0)
|
|
}
|
|
|
|
fn change_terminal_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
|
self.terminal_font_size = (self.terminal_font_size + delta).clamp(10.0, 24.0);
|
|
self.config.set_terminal_font_size(self.terminal_font_size);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save terminal font size: {err:#}");
|
|
}
|
|
self.status = format!("terminal font size: {:.0}px", self.terminal_font_size).into();
|
|
cx.notify();
|
|
}
|
|
|
|
fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
|
self.ui_font_size = (self.ui_font_size + delta).clamp(8.0, 24.0);
|
|
self.config.set_ui_font_size(self.ui_font_size);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save UI font size: {err:#}");
|
|
}
|
|
Theme::global_mut(cx).font_size = px(self.ui_font_size);
|
|
self.status = format!("UI font size: {:.0}px", self.ui_font_size).into();
|
|
cx.notify();
|
|
}
|
|
|
|
fn change_ui_font_family(
|
|
&mut self,
|
|
family: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.ui_font_family = family.into();
|
|
self.config.set_ui_font_family(family);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save UI font family: {err:#}");
|
|
}
|
|
set_theme_font_names(Theme::global_mut(cx), &self.ui_font_family);
|
|
cx.notify();
|
|
window.refresh();
|
|
}
|
|
|
|
fn change_terminal_font_family(&mut self, family: &str, cx: &mut Context<Self>) {
|
|
self.terminal_font_family = family.into();
|
|
self.config.set_terminal_font_family(family);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save terminal font family: {err:#}");
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn show_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let view = cx.entity();
|
|
let session_name_input = self.session_name_input.clone();
|
|
let host_input = self.host_input.clone();
|
|
let focus_host_input = host_input.clone();
|
|
let port_input = self.port_input.clone();
|
|
let user_input = self.user_input.clone();
|
|
let password_input = self.password_input.clone();
|
|
let key_path_input = self.key_path_input.clone();
|
|
let key_inline_input = self.key_inline_input.clone();
|
|
|
|
window.open_dialog(cx, move |dialog: Dialog, _window, _cx| {
|
|
dialog
|
|
.title(t!("new_ssh_connection"))
|
|
.w(px(520.))
|
|
.overlay_closable(true)
|
|
.content({
|
|
let view = view.clone();
|
|
let session_name_input = session_name_input.clone();
|
|
let host_input = host_input.clone();
|
|
let port_input = port_input.clone();
|
|
let user_input = user_input.clone();
|
|
let password_input = password_input.clone();
|
|
let key_path_input = key_path_input.clone();
|
|
let key_inline_input = key_inline_input.clone();
|
|
move |content, window, cx| {
|
|
let is_password = view.read(cx).ssh_auth_method == AuthMethod::Password;
|
|
let is_editing = view.read(cx).editing_session_id.is_some();
|
|
content.child(
|
|
v_flex()
|
|
.gap_3()
|
|
.child(Input::new(&session_name_input).tab_index(0))
|
|
.child(Input::new(&host_input).tab_index(1))
|
|
.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.child(Input::new(&port_input).w(px(96.)).tab_index(2))
|
|
.child(Input::new(&user_input).flex_1().tab_index(3)),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.child(
|
|
Button::new("ssh-auth-password")
|
|
.label(t!("password").to_string())
|
|
.when(is_password, |button| button.primary())
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, _, cx| {
|
|
this.set_ssh_auth_method(
|
|
AuthMethod::Password,
|
|
cx,
|
|
)
|
|
},
|
|
)),
|
|
)
|
|
.child(
|
|
Button::new("ssh-auth-key")
|
|
.label(t!("key").to_string())
|
|
.when(!is_password, |button| button.primary())
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, _, cx| {
|
|
this.set_ssh_auth_method(
|
|
AuthMethod::Key,
|
|
cx,
|
|
)
|
|
},
|
|
)),
|
|
),
|
|
)
|
|
.when(is_password, |this| {
|
|
this.child(
|
|
Input::new(&password_input).mask_toggle().tab_index(4),
|
|
)
|
|
})
|
|
.when(!is_password, |this| {
|
|
this.child(Input::new(&key_path_input).tab_index(5)).child(
|
|
Input::new(&key_inline_input).h(px(128.)).tab_index(6),
|
|
)
|
|
})
|
|
.child(
|
|
h_flex()
|
|
.justify_end()
|
|
.gap_2()
|
|
.child(
|
|
Button::new("connect-ssh-cancel")
|
|
.label(t!("cancel").to_string())
|
|
.on_click(|_, window, cx| window.close_dialog(cx)),
|
|
)
|
|
.child(
|
|
Button::new("connect-ssh-confirm")
|
|
.primary()
|
|
.label(if is_editing {
|
|
t!("save")
|
|
} else {
|
|
t!("connect")
|
|
})
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, window, cx| {
|
|
this.connect_ssh(window, cx)
|
|
},
|
|
)),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
})
|
|
});
|
|
window.defer(cx, move |window, cx| {
|
|
window.focus(&focus_host_input.read(cx).focus_handle(cx), cx);
|
|
});
|
|
}
|
|
|
|
fn show_selector_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let view = cx.entity();
|
|
let selector_focus_handle = self.selector_focus_handle.clone();
|
|
let deferred_selector_focus_handle = selector_focus_handle.clone();
|
|
let sessions = self.config.sessions().to_vec();
|
|
let active_session_id = self.active_session_id().map(ToOwned::to_owned);
|
|
self.selector_selection = self.default_selector_index();
|
|
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
|
|
dialog
|
|
.title(t!("open_session").to_string())
|
|
.w(px(520.))
|
|
.on_ok({
|
|
let view = view.clone();
|
|
move |_, window, cx| {
|
|
view.update(cx, |this, cx| {
|
|
this.activate_selector_selection(window, cx);
|
|
});
|
|
true
|
|
}
|
|
})
|
|
.content({
|
|
let view = view.clone();
|
|
let sessions = sessions.clone();
|
|
let _active_session_id = active_session_id.clone();
|
|
let selector_focus_handle = selector_focus_handle.clone();
|
|
move |content, window, _cx| {
|
|
let selected_index = view.read(_cx).selector_selection;
|
|
content.child(
|
|
v_flex()
|
|
.track_focus(&selector_focus_handle)
|
|
.on_key_down(window.listener_for(
|
|
&view,
|
|
|this, event, window, cx| {
|
|
this.on_selector_key_down(event, window, cx)
|
|
},
|
|
))
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.p_2()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(if selected_index == 0 {
|
|
_cx.theme().primary
|
|
} else {
|
|
_cx.theme().border
|
|
})
|
|
.bg(if selected_index == 0 {
|
|
_cx.theme().tab_active
|
|
} else {
|
|
_cx.theme().muted
|
|
})
|
|
.cursor_pointer()
|
|
.hover(|this| this.bg(_cx.theme().secondary))
|
|
.on_mouse_down(
|
|
MouseButton::Left,
|
|
window.listener_for(&view, |this, _, window, cx| {
|
|
this.open_local(cx);
|
|
window.close_dialog(cx);
|
|
}),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.child(t!("local_terminal")),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(_cx.theme().muted_foreground)
|
|
.child(t!("open_local_shell_tab")),
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.p_2()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(if selected_index == 1 {
|
|
_cx.theme().primary
|
|
} else {
|
|
_cx.theme().border
|
|
})
|
|
.bg(if selected_index == 1 {
|
|
_cx.theme().tab_active
|
|
} else {
|
|
_cx.theme().muted
|
|
})
|
|
.cursor_pointer()
|
|
.hover(|this| this.bg(_cx.theme().secondary))
|
|
.on_mouse_down(
|
|
MouseButton::Left,
|
|
window.listener_for(&view, |this, _, window, cx| {
|
|
window.close_dialog(cx);
|
|
this.open_new_ssh_dialog(window, cx);
|
|
}),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.child(t!("new_ssh_connection")),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(_cx.theme().muted_foreground)
|
|
.child(t!("create_or_edit_ssh_session")),
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.max_h(px(320.))
|
|
.overflow_scrollbar()
|
|
.gap_2()
|
|
.children(sessions.clone().into_iter().enumerate().map(
|
|
|(ix, session)| {
|
|
let connect_id = session.id.clone();
|
|
let is_selected = selected_index == ix + 2;
|
|
let name = session.name.clone();
|
|
let detail = format!(
|
|
"{}@{}:{}",
|
|
session.user, session.host, session.port
|
|
);
|
|
div()
|
|
.id(("selector-open", ix))
|
|
.w_full()
|
|
.p_2()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(if is_selected {
|
|
_cx.theme().primary
|
|
} else {
|
|
_cx.theme().border
|
|
})
|
|
.bg(if is_selected {
|
|
_cx.theme().tab_active
|
|
} else {
|
|
_cx.theme().muted
|
|
})
|
|
.cursor_pointer()
|
|
.hover(|this| this.bg(_cx.theme().secondary))
|
|
.on_mouse_down(
|
|
MouseButton::Left,
|
|
window.listener_for(
|
|
&view,
|
|
move |this, _, window, cx| {
|
|
this.connect_saved_session(
|
|
connect_id.clone(),
|
|
cx,
|
|
);
|
|
window.close_dialog(cx);
|
|
},
|
|
),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.font_weight(
|
|
FontWeight::SEMIBOLD,
|
|
)
|
|
.child(name),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(
|
|
_cx.theme()
|
|
.muted_foreground,
|
|
)
|
|
.child(detail),
|
|
),
|
|
)
|
|
},
|
|
)),
|
|
),
|
|
)
|
|
}
|
|
})
|
|
});
|
|
window.defer(cx, move |window, cx| {
|
|
window.focus(&deferred_selector_focus_handle, cx);
|
|
});
|
|
}
|
|
|
|
fn show_transfers_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let view = cx.entity();
|
|
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
|
|
dialog
|
|
.title(t!("transfers").to_string())
|
|
.w(px(600.))
|
|
.content({
|
|
let view = view.clone();
|
|
move |content, window, cx| {
|
|
let mut transfers = view.read(cx).transfers.clone();
|
|
transfers.sort_by_key(|t| {
|
|
match t.state {
|
|
crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused => 0,
|
|
_ => 1,
|
|
}
|
|
});
|
|
|
|
if transfers.is_empty() {
|
|
return content.child(
|
|
div()
|
|
.p_4()
|
|
.text_center()
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("no_transfers_yet").to_string()),
|
|
);
|
|
}
|
|
|
|
let list = v_flex().gap_2().children(transfers.into_iter().map(|t| {
|
|
let (icon, _color) = match t.info.kind {
|
|
crate::terminal::TransferType::Upload => (IconName::ArrowUp, cx.theme().primary),
|
|
crate::terminal::TransferType::Download => (IconName::ArrowDown, cx.theme().success),
|
|
};
|
|
|
|
let (status_text, actions) = match t.state {
|
|
crate::terminal::TransferState::Running => {
|
|
let percent = t.total.map(|tot| (t.transferred as f64 / tot as f64 * 100.0).clamp(0.0, 100.0)).unwrap_or(0.0);
|
|
let txt = if let Some(tot) = t.total {
|
|
format!("{:.1}% ({}/{})", percent, format_bytes(t.transferred), format_bytes(tot))
|
|
} else {
|
|
format!("{}...", t!("downloading"))
|
|
};
|
|
let btn_pause = Button::new(SharedString::from(format!("pause-{}", t.info.id)))
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Pause)
|
|
.on_click(window.listener_for(&view, {
|
|
let id = t.info.id.clone();
|
|
let tab_id = t.tab_id.clone();
|
|
move |this, _, _, _| {
|
|
if let Some(handle) = this.sftp_handles.get(&tab_id) {
|
|
handle.pause_transfer(id.clone());
|
|
}
|
|
}
|
|
}));
|
|
let btn_cancel = Button::new(SharedString::from(format!("cancel-{}", t.info.id)))
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Close)
|
|
.on_click(window.listener_for(&view, {
|
|
let id = t.info.id.clone();
|
|
let tab_id = t.tab_id.clone();
|
|
move |this, _, _, _| {
|
|
if let Some(handle) = this.sftp_handles.get(&tab_id) {
|
|
handle.cancel_transfer(id.clone());
|
|
}
|
|
}
|
|
}));
|
|
(txt, h_flex().gap_1().child(btn_pause).child(btn_cancel))
|
|
}
|
|
crate::terminal::TransferState::Paused => {
|
|
let txt = t!("paused").to_string();
|
|
let btn_resume = Button::new(SharedString::from(format!("resume-{}", t.info.id)))
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Play)
|
|
.on_click(window.listener_for(&view, {
|
|
let id = t.info.id.clone();
|
|
let tab_id = t.tab_id.clone();
|
|
move |this, _, _, _| {
|
|
if let Some(handle) = this.sftp_handles.get(&tab_id) {
|
|
handle.resume_transfer(id.clone());
|
|
}
|
|
}
|
|
}));
|
|
let btn_cancel = Button::new(SharedString::from(format!("cancel-{}", t.info.id)))
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Close)
|
|
.on_click(window.listener_for(&view, {
|
|
let id = t.info.id.clone();
|
|
let tab_id = t.tab_id.clone();
|
|
move |this, _, _, _| {
|
|
if let Some(handle) = this.sftp_handles.get(&tab_id) {
|
|
handle.cancel_transfer(id.clone());
|
|
}
|
|
}
|
|
}));
|
|
(txt, h_flex().gap_1().child(btn_resume).child(btn_cancel))
|
|
}
|
|
crate::terminal::TransferState::Completed => {
|
|
let txt = t!("completed").to_string();
|
|
let mut actions = h_flex().gap_1();
|
|
if matches!(t.info.kind, crate::terminal::TransferType::Download) {
|
|
let btn_folder = Button::new(SharedString::from(format!("folder-{}", t.info.id)))
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Folder)
|
|
.on_click({
|
|
let target = t.info.target.clone();
|
|
move |_, _, _| {
|
|
let _ = std::process::Command::new("open").arg(&target).spawn();
|
|
}
|
|
});
|
|
actions = actions.child(btn_folder);
|
|
}
|
|
(txt, actions)
|
|
}
|
|
crate::terminal::TransferState::Failed(ref err) => {
|
|
(format!("{}: {}", t!("failed"), err), h_flex().gap_1())
|
|
}
|
|
crate::terminal::TransferState::Cancelled => {
|
|
(t!("cancelled").to_string(), h_flex().gap_1())
|
|
}
|
|
};
|
|
|
|
let percent = match t.state {
|
|
crate::terminal::TransferState::Completed => 100.0,
|
|
_ => t.total.map(|tot| t.transferred as f64 / tot as f64 * 100.0).unwrap_or(0.0),
|
|
};
|
|
|
|
v_flex()
|
|
.gap_1()
|
|
.p_2()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().muted)
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(Button::new(SharedString::from(format!("icon-{}", t.info.id))).icon(icon).ghost().small().disabled(true))
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.min_w(px(0.))
|
|
.overflow_hidden()
|
|
.child(
|
|
div()
|
|
.text_size(px(12.))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.text_color(cx.theme().foreground)
|
|
.overflow_hidden()
|
|
.child(t.info.name.clone()),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(px(10.))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.overflow_hidden()
|
|
.child(format!("{}: {}", t!("session"), t.tab_title)),
|
|
)
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(px(11.))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(status_text),
|
|
)
|
|
.child(actions),
|
|
)
|
|
.when(matches!(t.state, crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused), |this| {
|
|
this.child(
|
|
Progress::new(format!("progress-{}", t.info.id))
|
|
.with_size(px(4.))
|
|
.value(percent as f32)
|
|
.color(cx.theme().primary)
|
|
.w_full(),
|
|
)
|
|
})
|
|
}));
|
|
|
|
let scroll_handle = window
|
|
.use_keyed_state("transfers-scroll", cx, |_, _| gpui::ScrollHandle::default())
|
|
.read(cx)
|
|
.clone();
|
|
|
|
content.child(
|
|
div()
|
|
.w_full()
|
|
.relative()
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.max_h(px(400.))
|
|
.flex_col()
|
|
.id("transfers-scroll-view")
|
|
.track_scroll(&scroll_handle)
|
|
.overflow_y_scroll()
|
|
.child(list)
|
|
)
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.right_0()
|
|
.bottom_0()
|
|
.w(px(16.))
|
|
.child(
|
|
Scrollbar::vertical(&scroll_handle)
|
|
.scrollbar_show(ScrollbarShow::Always)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
})
|
|
});
|
|
}
|
|
|
|
fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let view = cx.entity();
|
|
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
|
|
dialog
|
|
.title(t!("settings").to_string())
|
|
.w(px(560.))
|
|
.content({
|
|
let view = view.clone();
|
|
move |content, window, cx| {
|
|
content.child(
|
|
v_flex()
|
|
.gap_3()
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(
|
|
div()
|
|
.w(px(240.))
|
|
.child(t!("ui_font_size").to_string()),
|
|
)
|
|
.child(
|
|
Button::new("ui-font-size-down")
|
|
.label("-")
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, _, cx| {
|
|
this.change_ui_font_size(-1.0, cx)
|
|
},
|
|
)),
|
|
)
|
|
.child(div().min_w(px(64.)).text_center().child(format!(
|
|
"{:.0}px",
|
|
view.read(cx).ui_font_size
|
|
)))
|
|
.child(
|
|
Button::new("ui-font-size-up")
|
|
.label("+")
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, _, cx| {
|
|
this.change_ui_font_size(1.0, cx)
|
|
},
|
|
)),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(
|
|
div()
|
|
.w(px(240.))
|
|
.child(t!("terminal_font_size").to_string()),
|
|
)
|
|
.child(
|
|
Button::new("font-size-down")
|
|
.label("-")
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, _, cx| {
|
|
this.change_terminal_font_size(-1.0, cx)
|
|
},
|
|
)),
|
|
)
|
|
.child(div().min_w(px(64.)).text_center().child(format!(
|
|
"{:.0}px",
|
|
view.read(cx).terminal_font_size
|
|
)))
|
|
.child(
|
|
Button::new("font-size-up")
|
|
.label("+")
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, _, cx| {
|
|
this.change_terminal_font_size(1.0, cx)
|
|
},
|
|
)),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(
|
|
div()
|
|
.w(px(240.))
|
|
.child(t!("ui_font_family").to_string()),
|
|
)
|
|
.child(
|
|
Button::new("ui-font-dropdown")
|
|
.small()
|
|
.icon(IconName::ChevronsUpDown)
|
|
.label({
|
|
let current = view
|
|
.read(cx)
|
|
.ui_font_family
|
|
.to_string();
|
|
let names = cx.text_system().all_font_names();
|
|
if current == *".SystemUIFont"
|
|
|| current.is_empty()
|
|
|| !names.contains(¤t)
|
|
{
|
|
t!("system_default").to_string()
|
|
} else if current == "Maple Mono NF CN" {
|
|
format!("Maple Mono NF CN ({})", t!("software_builtin"))
|
|
} else {
|
|
current
|
|
}
|
|
})
|
|
.dropdown_menu_with_anchor(
|
|
Anchor::BottomRight,
|
|
{
|
|
let view = view.clone();
|
|
move |mut menu, window, cx| {
|
|
let current = view
|
|
.read(cx)
|
|
.ui_font_family
|
|
.to_string();
|
|
let mut names =
|
|
cx.text_system().all_font_names();
|
|
menu = menu.min_w(200.).max_h(
|
|
px(320.),
|
|
).scrollable(true);
|
|
// "System Default" entry
|
|
menu = menu.item(
|
|
PopupMenuItem::new(
|
|
t!(
|
|
"system_default"
|
|
)
|
|
.to_string(),
|
|
)
|
|
.checked(
|
|
current == *".SystemUIFont"
|
|
|| current
|
|
.is_empty(),
|
|
)
|
|
.on_click(window
|
|
.listener_for(
|
|
&view,
|
|
move |this, _, window,
|
|
cx| {
|
|
this.change_ui_font_family(
|
|
".SystemUIFont",
|
|
window,
|
|
cx,
|
|
);
|
|
},
|
|
)),
|
|
);
|
|
|
|
let maple_font = "Maple Mono NF CN".to_string();
|
|
if names.contains(&maple_font) {
|
|
names.retain(|n| n != &maple_font);
|
|
menu = menu.item(
|
|
PopupMenuItem::new(format!("{} ({})", maple_font, t!("software_builtin")))
|
|
.checked(current == maple_font)
|
|
.on_click(window.listener_for(&view, move |this, _, window, cx| {
|
|
this.change_ui_font_family("Maple Mono NF CN", window, cx);
|
|
}))
|
|
).separator();
|
|
}
|
|
|
|
for name in names {
|
|
let checked =
|
|
name == current;
|
|
menu = menu.item(
|
|
PopupMenuItem::new(
|
|
name.clone(),
|
|
)
|
|
.checked(checked)
|
|
.on_click(window
|
|
.listener_for(
|
|
&view,
|
|
move |this, _,
|
|
window, cx| {
|
|
this
|
|
.change_ui_font_family(
|
|
&name,
|
|
window,
|
|
cx,
|
|
);
|
|
},
|
|
)),
|
|
);
|
|
}
|
|
menu
|
|
}
|
|
},
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(
|
|
div()
|
|
.w(px(240.))
|
|
.child(t!("terminal_font_family").to_string()),
|
|
)
|
|
.child(
|
|
Button::new("terminal-font-dropdown")
|
|
.small()
|
|
.icon(IconName::ChevronsUpDown)
|
|
.label({
|
|
let current = view.read(cx).terminal_font_family.to_string();
|
|
if current == "Maple Mono NF CN" {
|
|
format!("Maple Mono NF CN ({})", t!("software_builtin"))
|
|
} else {
|
|
current
|
|
}
|
|
})
|
|
.dropdown_menu_with_anchor(
|
|
Anchor::BottomRight,
|
|
{
|
|
let view = view.clone();
|
|
move |mut menu, window, cx| {
|
|
let current = view
|
|
.read(cx)
|
|
.terminal_font_family
|
|
.to_string();
|
|
let mut names =
|
|
cx.text_system().all_font_names();
|
|
menu = menu.min_w(200.).max_h(
|
|
px(320.),
|
|
).scrollable(true);
|
|
|
|
let maple_font = "Maple Mono NF CN".to_string();
|
|
if names.contains(&maple_font) {
|
|
names.retain(|n| n != &maple_font);
|
|
menu = menu.item(
|
|
PopupMenuItem::new(format!("{} ({})", maple_font, t!("software_builtin")))
|
|
.checked(current == maple_font)
|
|
.on_click(window.listener_for(&view, move |this, _, _window, cx| {
|
|
this.change_terminal_font_family("Maple Mono NF CN", cx);
|
|
}))
|
|
).separator();
|
|
}
|
|
|
|
for name in names {
|
|
let checked =
|
|
name == current;
|
|
menu = menu.item(
|
|
PopupMenuItem::new(
|
|
name.clone(),
|
|
)
|
|
.checked(checked)
|
|
.on_click(window
|
|
.listener_for(
|
|
&view,
|
|
move |this, _,
|
|
_window, cx| {
|
|
this
|
|
.change_terminal_font_family(
|
|
&name,
|
|
cx,
|
|
);
|
|
},
|
|
)),
|
|
);
|
|
}
|
|
menu
|
|
}
|
|
},
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.gap_1()
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(div().w(px(240.)).child(t!("right_click_copy_paste").to_string()))
|
|
.child(
|
|
Switch::new("right-click-copy-paste")
|
|
.checked(view.read(cx).config.right_click_copy_paste())
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, checked, _, cx| {
|
|
this.config.set_right_click_copy_paste(*checked);
|
|
let _ = this.config.save();
|
|
cx.notify();
|
|
},
|
|
)),
|
|
)
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.85))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("copy_paste_hint", key = if cfg!(target_os = "macos") { "Command" } else { "Ctrl" }).to_string())
|
|
)
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(div().w(px(240.)).child(t!("language").to_string()))
|
|
.child(
|
|
Button::new("language-dropdown")
|
|
.small()
|
|
.icon(IconName::Globe)
|
|
.label({
|
|
let current_locale =
|
|
view.read(cx).config.locale().to_string();
|
|
if current_locale == "en" {
|
|
t!("english").to_string()
|
|
} else if current_locale == "zh-CN" {
|
|
t!("chinese").to_string()
|
|
} else {
|
|
t!("follow_system").to_string()
|
|
}
|
|
})
|
|
.dropdown_menu_with_anchor(Anchor::BottomRight, {
|
|
let view = view.clone();
|
|
move |mut menu, window, cx| {
|
|
let current_locale = view
|
|
.read(cx)
|
|
.config
|
|
.locale()
|
|
.to_string();
|
|
menu = menu
|
|
.min_w(160.)
|
|
.item(
|
|
PopupMenuItem::new(
|
|
t!("follow_system").to_string(),
|
|
)
|
|
.checked(current_locale == "system")
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, window, cx| {
|
|
this.set_display_language(
|
|
"system", window, cx,
|
|
)
|
|
},
|
|
)),
|
|
)
|
|
.separator()
|
|
.item(
|
|
PopupMenuItem::new(
|
|
t!("english").to_string(),
|
|
)
|
|
.checked(current_locale == "en")
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, window, cx| {
|
|
this.set_display_language(
|
|
"en", window, cx,
|
|
)
|
|
},
|
|
)),
|
|
)
|
|
.item(
|
|
PopupMenuItem::new(
|
|
t!("chinese").to_string(),
|
|
)
|
|
.checked(current_locale == "zh-CN")
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, window, cx| {
|
|
this.set_display_language(
|
|
"zh-CN", window, cx,
|
|
)
|
|
},
|
|
)),
|
|
);
|
|
menu
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(
|
|
div().w(px(240.)).child(t!("reset_layout").to_string()),
|
|
)
|
|
.child(
|
|
Button::new("reset-layout")
|
|
.label(t!("reset").to_string())
|
|
.on_click(window.listener_for(
|
|
&view,
|
|
|this, _, window, cx| {
|
|
this.reset_layout(window, cx);
|
|
},
|
|
)),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("theme_management_hint")),
|
|
),
|
|
)
|
|
}
|
|
})
|
|
});
|
|
}
|
|
|
|
fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
self.config.set_layout_state(None, None, None);
|
|
let _ = self.config.save();
|
|
|
|
self.workspace_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
|
|
self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
|
|
|
|
self.status = t!("reset_layout_success").into();
|
|
cx.notify();
|
|
}
|
|
|
|
fn set_ssh_auth_method(&mut self, method: AuthMethod, cx: &mut Context<Self>) {
|
|
self.ssh_auth_method = method;
|
|
cx.notify();
|
|
}
|
|
|
|
fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
|
|
let Some(session) = self.config.get(&session_id).cloned() else {
|
|
self.status = "saved session not found".into();
|
|
cx.notify();
|
|
return;
|
|
};
|
|
self.open_ssh_session(session, cx);
|
|
}
|
|
|
|
fn selector_entries(&self) -> Vec<SelectorEntry> {
|
|
let mut entries = vec![SelectorEntry::Local, SelectorEntry::NewSsh];
|
|
entries.extend(
|
|
self.config
|
|
.sessions()
|
|
.iter()
|
|
.map(|session| SelectorEntry::Saved(session.id.clone())),
|
|
);
|
|
entries
|
|
}
|
|
|
|
fn default_selector_index(&self) -> usize {
|
|
if self.config.sessions().is_empty() {
|
|
0
|
|
} else {
|
|
2
|
|
}
|
|
}
|
|
|
|
fn move_selector_selection(&mut self, delta: i32, cx: &mut Context<Self>) {
|
|
let entries = self.selector_entries();
|
|
if entries.is_empty() {
|
|
return;
|
|
}
|
|
let current = self.selector_selection.min(entries.len().saturating_sub(1)) as i32;
|
|
let next = (current + delta).clamp(0, entries.len() as i32 - 1) as usize;
|
|
if next != self.selector_selection {
|
|
self.selector_selection = next;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn activate_selector_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let entries = self.selector_entries();
|
|
let Some(entry) = entries.get(self.selector_selection).cloned() else {
|
|
return;
|
|
};
|
|
|
|
match entry {
|
|
SelectorEntry::Local => {
|
|
self.open_local(cx);
|
|
window.close_dialog(cx);
|
|
}
|
|
SelectorEntry::NewSsh => {
|
|
window.close_dialog(cx);
|
|
self.open_new_ssh_dialog(window, cx);
|
|
}
|
|
SelectorEntry::Saved(session_id) => {
|
|
self.connect_saved_session(session_id, cx);
|
|
window.close_dialog(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_selector_key_down(
|
|
&mut self,
|
|
event: &KeyDownEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let key = event.keystroke.key.to_ascii_lowercase();
|
|
match key.as_str() {
|
|
"up" | "arrowup" => {
|
|
self.move_selector_selection(-1, cx);
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
"down" | "arrowdown" => {
|
|
self.move_selector_selection(1, cx);
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
"enter" | "return" => {
|
|
self.activate_selector_selection(window, cx);
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn open_ssh_session(&mut self, session: Session, cx: &mut Context<Self>) {
|
|
let id = Uuid::new_v4().to_string();
|
|
let backend = ssh_terminal::spawn_ssh_terminal(
|
|
self.runtime.handle(),
|
|
id.clone(),
|
|
session.clone(),
|
|
DEFAULT_COLS,
|
|
DEFAULT_ROWS,
|
|
self.events_tx.clone(),
|
|
);
|
|
self.tabs.push(TerminalTab::new_ssh(
|
|
id.clone(),
|
|
&session,
|
|
backend,
|
|
self.events_tx.clone(),
|
|
));
|
|
let sftp_handle = sftp::spawn_sftp(
|
|
self.runtime.handle(),
|
|
id.clone(),
|
|
session,
|
|
self.events_tx.clone(),
|
|
);
|
|
self.sftp_handles.insert(id.clone(), sftp_handle);
|
|
self.active_tab = Some(id.clone());
|
|
self.pending_sftp_path_sync = Some("/".into());
|
|
self.connection_progress = Some(ConnectionProgress {
|
|
tab_id: id,
|
|
title: t!("connecting").into(),
|
|
lines: vec![t!("starting_connection").into()],
|
|
failed: false,
|
|
});
|
|
self.status = "ssh tab opened".into();
|
|
cx.notify();
|
|
}
|
|
|
|
fn remove_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
|
|
self.config.remove(&session_id);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save config: {err:#}");
|
|
}
|
|
self.status = "session removed".into();
|
|
cx.notify();
|
|
}
|
|
|
|
fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
|
|
let Some(progress) = self.connection_progress.clone() else {
|
|
return;
|
|
};
|
|
let Some(ix) = self.tabs.iter().position(|tab| tab.id == progress.tab_id) else {
|
|
self.connection_progress = None;
|
|
cx.notify();
|
|
return;
|
|
};
|
|
let Some(session) = self.tabs[ix].session.clone() else {
|
|
self.connection_progress = None;
|
|
cx.notify();
|
|
return;
|
|
};
|
|
|
|
self.tabs[ix].backend.send(BackendCommand::Close);
|
|
if let Some(handle) = self.sftp_handles.remove(&progress.tab_id) {
|
|
handle.close();
|
|
}
|
|
self.tabs.remove(ix);
|
|
if self.active_tab.as_deref() == Some(progress.tab_id.as_str()) {
|
|
self.active_tab = self
|
|
.tabs
|
|
.get(ix)
|
|
.or_else(|| self.tabs.get(ix.saturating_sub(1)))
|
|
.map(|tab| tab.id.clone());
|
|
}
|
|
self.connection_progress = None;
|
|
self.open_ssh_session(session, cx);
|
|
}
|
|
|
|
fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
|
|
let Some(progress) = self.connection_progress.clone() else {
|
|
return;
|
|
};
|
|
self.connection_progress = None;
|
|
self.close_tab(progress.tab_id, cx);
|
|
}
|
|
|
|
fn activate_tab(&mut self, id: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(path) = self
|
|
.tabs
|
|
.iter()
|
|
.find(|tab| tab.id == id)
|
|
.and_then(|tab| tab.sftp.as_ref())
|
|
.map(|sftp| sftp.current_path.clone())
|
|
{
|
|
self.pending_sftp_path_sync = Some(path);
|
|
}
|
|
self.active_tab = Some(id);
|
|
self.remote_sample_in_flight = false;
|
|
self.request_active_system_snapshot();
|
|
self.focus_handle.focus(window, cx);
|
|
cx.notify();
|
|
}
|
|
|
|
fn close_tab(&mut self, id: String, cx: &mut Context<Self>) {
|
|
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
|
|
let was_active = self.active_tab.as_deref() == Some(id.as_str());
|
|
self.tabs[ix].backend.send(BackendCommand::Close);
|
|
if let Some(handle) = self.sftp_handles.remove(&id) {
|
|
handle.close();
|
|
}
|
|
self.tabs.remove(ix);
|
|
if was_active
|
|
|| self
|
|
.active_tab
|
|
.as_ref()
|
|
.is_some_and(|active_id| !self.tabs.iter().any(|tab| &tab.id == active_id))
|
|
{
|
|
self.active_tab = self
|
|
.tabs
|
|
.get(ix)
|
|
.or_else(|| self.tabs.get(ix.saturating_sub(1)))
|
|
.map(|tab| tab.id.clone());
|
|
self.remote_sample_in_flight = false;
|
|
self.request_active_system_snapshot();
|
|
}
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|tab| &tab.id == id))
|
|
.and_then(|tab| tab.sftp.as_ref())
|
|
}
|
|
|
|
fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
|
|
let active_id = self.active_tab.clone()?;
|
|
self.tabs
|
|
.iter_mut()
|
|
.find(|tab| tab.id == active_id)
|
|
.and_then(|tab| tab.sftp.as_mut())
|
|
}
|
|
|
|
fn active_sftp_handle(&self) -> Option<&SftpHandle> {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.sftp_handles.get(id))
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
fn dismiss_sftp_context_menu(&mut self, cx: &mut Context<Self>) {
|
|
if self.sftp_context_menu.take().is_some() {
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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:#}");
|
|
}
|
|
}
|
|
|
|
fn on_terminal_key_down(
|
|
&mut self,
|
|
event: &KeyDownEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
fn on_terminal_tab_action(
|
|
&mut self,
|
|
_: &TerminalTabKey,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.send_terminal_input(vec![b'\t'], window, cx);
|
|
}
|
|
|
|
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 terminal_ime_bounds_for_range(
|
|
&self,
|
|
range_utf16: Range<usize>,
|
|
element_bounds: Bounds<Pixels>,
|
|
) -> Option<Bounds<Pixels>> {
|
|
let snapshot = self.active_snapshot()?;
|
|
let cursor = snapshot.cursor?;
|
|
let x = element_bounds.origin.x
|
|
+ px(self.terminal_cell_width()) * cursor.col as f32
|
|
+ px(self.terminal_cell_width()) * range_utf16.start as f32;
|
|
let y = element_bounds.origin.y + px(self.terminal_line_height()) * cursor.row as f32;
|
|
Some(Bounds::new(
|
|
point(x, y),
|
|
size(
|
|
px(self.terminal_cell_width()),
|
|
px(self.terminal_line_height()),
|
|
),
|
|
))
|
|
}
|
|
|
|
fn focus_terminal(
|
|
&mut self,
|
|
event: &MouseDownEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.focus_handle.focus(window, cx);
|
|
if event.button == MouseButton::Left {
|
|
self.begin_terminal_selection(event, cx);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
fn on_terminal_mouse_move(
|
|
&mut self,
|
|
event: &MouseMoveEvent,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
fn on_terminal_mouse_up(
|
|
&mut self,
|
|
_event: &MouseUpEvent,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.terminal_selecting = false;
|
|
cx.notify();
|
|
}
|
|
|
|
fn terminal_grid_point_and_side(
|
|
&self,
|
|
position: Point<Pixels>,
|
|
) -> Option<(usize, usize, Side)> {
|
|
let bounds = self.terminal_bounds?;
|
|
if !bounds.contains(&position) {
|
|
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))
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
fn active_snapshot(&self) -> Option<terminal::RenderSnapshot> {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
|
.map(TerminalTab::render_snapshot)
|
|
}
|
|
|
|
fn render_home_page(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
v_flex()
|
|
.w_full()
|
|
.h_full()
|
|
.items_center()
|
|
.justify_center()
|
|
.gap_4()
|
|
.child(
|
|
div()
|
|
.text_size(rems(2.333))
|
|
.font_weight(FontWeight::BOLD)
|
|
.child("ashell"),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.083))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("open_local_or_ssh")),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.gap_3()
|
|
.child(
|
|
Button::new("home-open-local")
|
|
.primary()
|
|
.label(t!("local_terminal").to_string())
|
|
.on_click(cx.listener(|this, _, _, cx| this.open_local(cx))),
|
|
)
|
|
.child(
|
|
Button::new("home-open-session")
|
|
.ghost()
|
|
.label(t!("open_session").to_string())
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.show_selector_dialog(window, cx)
|
|
})),
|
|
),
|
|
)
|
|
}
|
|
|
|
fn active_status(&self) -> String {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
|
.map(|t| t.status.clone())
|
|
.unwrap_or_else(|| self.status.to_string())
|
|
}
|
|
|
|
fn active_connection_ok(&self) -> bool {
|
|
let status = self.active_status().to_ascii_lowercase();
|
|
let has_error = ["failed", "error", "closed", "unavailable", "required"]
|
|
.iter()
|
|
.any(|needle| status.contains(needle));
|
|
!has_error && self.active_tab.is_some()
|
|
}
|
|
|
|
fn active_connection_color(&self, cx: &mut Context<Self>) -> Hsla {
|
|
if self.active_connection_ok() {
|
|
cx.theme().success
|
|
} else {
|
|
cx.theme().danger
|
|
}
|
|
}
|
|
|
|
fn active_kind(&self) -> Option<TabKind> {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
|
.map(|tab| tab.kind)
|
|
}
|
|
|
|
fn active_title(&self) -> String {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
|
.map(|t| t.title.clone())
|
|
.unwrap_or_else(|| t!("idle_no_session").into())
|
|
}
|
|
|
|
fn active_ssh_session(&self) -> Option<(String, Session)> {
|
|
let active_id = self.active_tab.as_ref()?;
|
|
let tab = self.tabs.iter().find(|tab| &tab.id == active_id)?;
|
|
if !tab.connected {
|
|
return None;
|
|
}
|
|
Some((tab.id.clone(), tab.session.clone()?))
|
|
}
|
|
|
|
fn active_session_id(&self) -> Option<&str> {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|tab| &tab.id == id))
|
|
.and_then(|tab| tab.session.as_ref())
|
|
.map(|session| session.id.as_str())
|
|
}
|
|
|
|
fn session_detail(&self, session: &Session) -> String {
|
|
format!("{}@{}:{}", session.user, session.host, session.port)
|
|
}
|
|
|
|
fn system_target_label(&self) -> String {
|
|
match self.active_kind() {
|
|
Some(TabKind::Ssh) => self.active_title(),
|
|
Some(TabKind::Local) => "local host".into(),
|
|
None => "idle".into(),
|
|
}
|
|
}
|
|
|
|
fn sync_terminal_size(&mut self, window: &Window, cx: &App) {
|
|
let viewport = window.viewport_size();
|
|
let sidebar_width = self
|
|
.workspace_panels
|
|
.read(cx)
|
|
.sizes()
|
|
.first()
|
|
.map(|size| size.as_f32())
|
|
.unwrap_or(SIDEBAR_WIDTH);
|
|
let terminal_height = self
|
|
.body_panels
|
|
.read(cx)
|
|
.sizes()
|
|
.first()
|
|
.map(|size| size.as_f32())
|
|
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
|
|
let width = (viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
|
|
.max(self.terminal_cell_width());
|
|
let height = (terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height());
|
|
let cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16;
|
|
let rows = (height / self.terminal_line_height()).floor().max(1.0) as u16;
|
|
|
|
for tab in &mut self.tabs {
|
|
tab.resize(cols, rows);
|
|
}
|
|
}
|
|
|
|
fn system_card(
|
|
&self,
|
|
label: String,
|
|
percent: f32,
|
|
detail: String,
|
|
fill: Hsla,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
let percent = percent.clamp(0.0, 1.0);
|
|
v_flex()
|
|
.gap_1()
|
|
.p_2()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().muted)
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.text_color(fill)
|
|
.child(label.clone()),
|
|
)
|
|
.child(div().flex_1())
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.flex_none()
|
|
.child(format!("{:.0}%", percent * 100.0)),
|
|
),
|
|
)
|
|
.child(
|
|
Progress::new(format!("system-progress-{label}"))
|
|
.with_size(px(6.))
|
|
.value(percent * 100.0)
|
|
.color(fill)
|
|
.w_full(),
|
|
)
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.min_w(px(0.))
|
|
.overflow_hidden()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(detail),
|
|
)
|
|
}
|
|
|
|
fn network_card(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
v_flex()
|
|
.gap_2()
|
|
.p_2()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().muted)
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.text_color(cx.theme().chart_4)
|
|
.child(t!("net")),
|
|
)
|
|
.child(div().flex_1())
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("live")),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.child(
|
|
h_flex()
|
|
.flex_1()
|
|
.min_w(px(0.))
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.flex_none()
|
|
.text_size(rems(1.0))
|
|
.text_color(cx.theme().chart_4)
|
|
.child("↓"),
|
|
)
|
|
.child(div().text_size(rems(1.0)).child(self.system.net_rx.clone())),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.flex_1()
|
|
.min_w(px(0.))
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.flex_none()
|
|
.text_size(rems(1.0))
|
|
.text_color(cx.theme().chart_5)
|
|
.child("↑"),
|
|
)
|
|
.child(div().text_size(rems(1.0)).child(self.system.net_tx.clone())),
|
|
),
|
|
)
|
|
}
|
|
|
|
fn disk_row(&self, disk: DiskSample, cx: &mut Context<Self>) -> impl IntoElement + use<> {
|
|
let mount = disk.mount.clone();
|
|
let used = disk.total_bytes.saturating_sub(disk.available_bytes);
|
|
let percent = if disk.total_bytes == 0 {
|
|
0.0
|
|
} else {
|
|
used as f32 / disk.total_bytes as f32
|
|
}
|
|
.clamp(0.0, 1.0);
|
|
|
|
v_flex()
|
|
.gap_1()
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w(px(0.))
|
|
.overflow_hidden()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().foreground)
|
|
.child(mount.clone()),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex_none()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(format!("{:.0}%", percent * 100.0)),
|
|
),
|
|
)
|
|
.child(
|
|
Progress::new(format!("disk-progress-{mount}"))
|
|
.with_size(px(5.))
|
|
.value(percent * 100.0)
|
|
.color(cx.theme().chart_5)
|
|
.w_full(),
|
|
)
|
|
.child(
|
|
div()
|
|
.min_w(px(0.))
|
|
.overflow_hidden()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(format!(
|
|
"{}/{}",
|
|
format_bytes(used),
|
|
format_bytes(disk.total_bytes)
|
|
)),
|
|
)
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
fn render_sftp_panel(
|
|
&mut self,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
let active_sftp = self.active_sftp();
|
|
|
|
let header = h_flex()
|
|
.flex_none()
|
|
.h(px(34.))
|
|
.items_center()
|
|
.gap_2()
|
|
.px_3()
|
|
.border_b_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().tab_bar)
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.text_color(cx.theme().primary)
|
|
.child(t!("remote_files")),
|
|
)
|
|
.child(div().flex_1())
|
|
.when_some(active_sftp.clone(), |this, sftp| {
|
|
let selected_entries = sftp.selected_entries.clone();
|
|
this.child(
|
|
Button::new("sftp-refresh")
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::ArrowRight)
|
|
.label(t!("refresh").to_string())
|
|
.on_click(cx.listener(|this, _, _, cx| this.refresh_sftp(cx))),
|
|
)
|
|
.child(
|
|
Button::new("sftp-upload-file")
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Plus)
|
|
.label(t!("upload_file").to_string())
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.upload_sftp_files(window, cx)
|
|
})),
|
|
)
|
|
.child(
|
|
Button::new("sftp-upload-folder")
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Folder)
|
|
.label(t!("upload_folder").to_string())
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.upload_sftp_folder(window, cx)
|
|
})),
|
|
)
|
|
.child(
|
|
Button::new("sftp-download-selected")
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::ArrowDown)
|
|
.label(if selected_entries.is_empty() {
|
|
t!("download").to_string()
|
|
} else {
|
|
t!("download_count", count = selected_entries.len()).to_string()
|
|
})
|
|
.disabled(selected_entries.is_empty())
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.download_selected_sftp_entries(window, cx);
|
|
})),
|
|
)
|
|
.child(
|
|
Checkbox::new("sftp-show-hidden")
|
|
.small()
|
|
.label(t!("hidden").to_string())
|
|
.checked(self.show_hidden_files)
|
|
.tab_stop(false)
|
|
.on_click(cx.listener(|this, checked, _, cx| {
|
|
this.show_hidden_files = *checked;
|
|
cx.notify();
|
|
})),
|
|
)
|
|
})
|
|
.child(
|
|
Button::new("open-transfers")
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::ArrowDown)
|
|
.label(t!("transfers").to_string())
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.show_transfers_dialog(window, cx);
|
|
})),
|
|
);
|
|
|
|
let Some(sftp) = active_sftp else {
|
|
return v_flex()
|
|
.size_full()
|
|
.gap_0()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().background)
|
|
.child(header)
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.items_center()
|
|
.justify_center()
|
|
.p_3()
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("open_ssh_tab_sftp")),
|
|
),
|
|
)
|
|
.into_any_element();
|
|
};
|
|
|
|
let selected_path = sftp.selected_path.clone();
|
|
let entries = sftp
|
|
.entries
|
|
.clone()
|
|
.into_iter()
|
|
.filter(|entry| self.show_hidden_files || !entry.name.starts_with('.'))
|
|
.collect::<Vec<_>>();
|
|
let status = sftp.status.clone();
|
|
let selected_entries = sftp.selected_entries.clone();
|
|
let all_selected = !entries.is_empty()
|
|
&& entries
|
|
.iter()
|
|
.all(|e| selected_entries.contains(&e.full_path));
|
|
let parent_path = Self::sftp_parent_path(&sftp.current_path);
|
|
let view = cx.entity();
|
|
let icon_col_width = px(14.);
|
|
let size_col_width = px(96.);
|
|
let modified_col_width = px(152.);
|
|
|
|
v_flex()
|
|
.size_full()
|
|
.gap_0()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().background)
|
|
.on_drop(
|
|
cx.listener(|this, paths: &gpui::ExternalPaths, _window, cx| {
|
|
let paths_to_upload: Vec<String> = paths
|
|
.0
|
|
.iter()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.collect();
|
|
this.upload_sftp_files_batch(paths_to_upload, cx);
|
|
}),
|
|
)
|
|
.child(header)
|
|
.child(
|
|
h_flex()
|
|
.h(px(36.))
|
|
.items_center()
|
|
.gap_2()
|
|
.px_3()
|
|
.border_b_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().muted)
|
|
.child(
|
|
Button::new("sftp-up")
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::ChevronUp)
|
|
.on_click(cx.listener(move |this, _, _, cx| {
|
|
this.navigate_sftp(parent_path.clone(), cx);
|
|
})),
|
|
)
|
|
.child(Input::new(&self.sftp_path_input).flex_1().tab_index(0))
|
|
.child(div().flex_none()),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.h(px(26.))
|
|
.px_3()
|
|
.items_center()
|
|
.gap_2()
|
|
.border_b_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().muted.opacity(0.8))
|
|
.child(
|
|
h_flex()
|
|
.w(px(24.))
|
|
.flex_none()
|
|
.items_center()
|
|
.justify_center()
|
|
.child(
|
|
Checkbox::new("sftp-select-all")
|
|
.checked(all_selected)
|
|
.on_click(cx.listener(move |this, checked, _, cx| {
|
|
this.toggle_all_sftp_entries(*checked, cx);
|
|
})),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.flex_1()
|
|
.min_w(px(0.))
|
|
.items_center()
|
|
.gap_2()
|
|
.child(div().w(icon_col_width).flex_none())
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("name")),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.w(size_col_width)
|
|
.flex_none()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("size")),
|
|
)
|
|
.child(
|
|
div()
|
|
.w(modified_col_width)
|
|
.flex_none()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(t!("modified")),
|
|
)
|
|
.child(div().w(px(12.)).flex_none()),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_h(px(0.))
|
|
.w_full()
|
|
.relative()
|
|
.child(
|
|
uniform_list("remote-files-list", entries.len(), {
|
|
let entries = entries.clone();
|
|
let selected_path = selected_path.clone();
|
|
let view = view.clone();
|
|
move |visible_range, window, cx| {
|
|
visible_range
|
|
.map(|ix| {
|
|
let entry = entries[ix].clone();
|
|
let left_row = entry.clone();
|
|
let right_row = entry.clone();
|
|
let remote_path = entry.full_path.clone();
|
|
let is_checked =
|
|
selected_entries.contains(&entry.full_path);
|
|
let is_selected = selected_path
|
|
.as_deref()
|
|
.map(|path| path == entry.full_path)
|
|
.unwrap_or(false);
|
|
let bg = if is_selected {
|
|
cx.theme().tab_active
|
|
} else if ix % 2 == 0 {
|
|
cx.theme().background
|
|
} else {
|
|
cx.theme().muted.opacity(0.45)
|
|
};
|
|
let name_color = if entry.is_dir {
|
|
cx.theme().primary
|
|
} else {
|
|
cx.theme().foreground
|
|
};
|
|
h_flex()
|
|
.w_full()
|
|
.h(px(28.))
|
|
.items_center()
|
|
.gap_2()
|
|
.px_3()
|
|
.bg(bg)
|
|
.hover(|style| style.bg(cx.theme().muted.opacity(0.8)))
|
|
.border_b_1()
|
|
.border_color(cx.theme().border.opacity(0.35))
|
|
.on_mouse_down(
|
|
MouseButton::Left,
|
|
window.listener_for(
|
|
&view,
|
|
move |this, _, _, cx| {
|
|
this.dismiss_sftp_context_menu(cx);
|
|
this.select_sftp_entry(
|
|
left_row.clone(),
|
|
cx,
|
|
);
|
|
},
|
|
),
|
|
)
|
|
.on_mouse_down(
|
|
MouseButton::Right,
|
|
window.listener_for(&view, {
|
|
let remote_path = remote_path.clone();
|
|
move |this, event: &MouseDownEvent, _, cx| {
|
|
this.mark_sftp_entry_selected(
|
|
&right_row.full_path,
|
|
cx,
|
|
);
|
|
this.open_sftp_context_menu(
|
|
remote_path.clone(),
|
|
entry.is_dir,
|
|
event.position,
|
|
cx,
|
|
);
|
|
}
|
|
}),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.w(px(24.))
|
|
.flex_none()
|
|
.items_center()
|
|
.justify_center()
|
|
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
|
cx.stop_propagation()
|
|
})
|
|
.on_mouse_down(
|
|
MouseButton::Right,
|
|
|_, _, cx| cx.stop_propagation(),
|
|
)
|
|
.child(
|
|
Checkbox::new(ElementId::Name(
|
|
format!("check-{}", entry.full_path)
|
|
.into(),
|
|
))
|
|
.checked(is_checked)
|
|
.on_click(window.listener_for(&view, {
|
|
let path = entry.full_path.clone();
|
|
move |this, checked, _, cx| {
|
|
this.toggle_sftp_entry(
|
|
path.clone(),
|
|
*checked,
|
|
cx,
|
|
);
|
|
}
|
|
})),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.flex_1()
|
|
.min_w(px(0.))
|
|
.items_center()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.w(icon_col_width)
|
|
.flex_none()
|
|
.text_size(rems(1.0))
|
|
.text_color(name_color)
|
|
.child(if entry.is_dir {
|
|
"📁"
|
|
} else {
|
|
"📄"
|
|
}),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w(px(0.))
|
|
.overflow_hidden()
|
|
.text_size(rems(1.0))
|
|
.text_color(name_color)
|
|
.child(entry.name),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.w(size_col_width)
|
|
.flex_none()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(if entry.is_dir {
|
|
"-".to_string()
|
|
} else {
|
|
format_bytes(entry.size)
|
|
}),
|
|
)
|
|
.child(
|
|
div()
|
|
.w(modified_col_width)
|
|
.flex_none()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(format_mtime(entry.modified)),
|
|
)
|
|
.child(div().w(px(12.)).flex_none())
|
|
.into_any_element()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
}
|
|
})
|
|
.size_full()
|
|
.track_scroll(&self.remote_files_scroll_handle),
|
|
)
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.right_0()
|
|
.bottom_0()
|
|
.w(px(16.))
|
|
.child(
|
|
Scrollbar::vertical(&self.remote_files_scroll_handle)
|
|
.scrollbar_show(ScrollbarShow::Always),
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.flex_none()
|
|
.h(px(24.))
|
|
.px_3()
|
|
.items_center()
|
|
.border_t_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().tab_bar)
|
|
.child(
|
|
div()
|
|
.min_w(px(0.))
|
|
.overflow_hidden()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(status),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let sessions = self.config.sessions().to_vec();
|
|
let active_session_id = self.active_session_id().map(ToOwned::to_owned);
|
|
let connection_color = self.active_connection_color(cx);
|
|
|
|
v_flex()
|
|
.gap_4()
|
|
.w_full()
|
|
.h_full()
|
|
.min_w(px(0.))
|
|
.p_4()
|
|
.border_r_1()
|
|
.border_color(cx.theme().sidebar_border)
|
|
.bg(cx.theme().sidebar)
|
|
.overflow_y_scrollbar()
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.gap_3()
|
|
.child(
|
|
div()
|
|
.w(px(10.))
|
|
.h(px(10.))
|
|
.rounded_full()
|
|
.bg(connection_color),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.font_weight(FontWeight::BOLD)
|
|
.text_size(rems(1.667))
|
|
.text_color(cx.theme().primary)
|
|
.child("ashell"),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child({
|
|
if let Some(kind) = self.active_kind() {
|
|
let kind_str = match kind {
|
|
TabKind::Local => t!("local_terminal").to_string(),
|
|
TabKind::Ssh => "ssh".to_string(),
|
|
};
|
|
format!("{} / {}", kind_str, self.active_title())
|
|
} else {
|
|
self.active_title()
|
|
}
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.gap_2()
|
|
.child(
|
|
h_flex()
|
|
.items_center()
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.text_color(cx.theme().primary)
|
|
.child(t!("system")),
|
|
)
|
|
.child(div().flex_1())
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(self.system_target_label()),
|
|
),
|
|
)
|
|
.when_some(self.system_status.clone(), |this, text| {
|
|
this.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(text),
|
|
)
|
|
})
|
|
.child(self.system_card(
|
|
t!("cpu").to_string(),
|
|
self.system.cpu_percent,
|
|
t!("global_load").into(),
|
|
cx.theme().chart_1,
|
|
cx,
|
|
))
|
|
.child(self.system_card(
|
|
t!("mem").to_string(),
|
|
self.system.mem_percent,
|
|
self.system.mem_detail.clone(),
|
|
cx.theme().chart_2,
|
|
cx,
|
|
))
|
|
.child(self.system_card(
|
|
t!("swap").to_string(),
|
|
self.system.swap_percent,
|
|
self.system.swap_detail.clone(),
|
|
cx.theme().chart_3,
|
|
cx,
|
|
))
|
|
.child(self.network_card(cx))
|
|
.child(
|
|
v_flex()
|
|
.gap_2()
|
|
.p_2()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().muted)
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().chart_5)
|
|
.child(t!("disk")),
|
|
)
|
|
.children({
|
|
let mut disk_elements = Vec::new();
|
|
for disk in self.system.disks.clone() {
|
|
disk_elements.push(self.disk_row(disk, cx));
|
|
}
|
|
disk_elements
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
Button::new("open-ssh-panel")
|
|
.primary()
|
|
.label(t!("add_ssh").to_string())
|
|
.on_click(
|
|
cx.listener(|this, _, window, cx| this.open_new_ssh_dialog(window, cx)),
|
|
),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.text_color(cx.theme().primary)
|
|
.child(t!("saved")),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.max_h(px(220.))
|
|
.overflow_scrollbar()
|
|
.gap_2()
|
|
.children(sessions.into_iter().enumerate().map(|(ix, session)| {
|
|
let connect_id = session.id.clone();
|
|
let edit_id = session.id.clone();
|
|
let delete_id = session.id.clone();
|
|
let is_active =
|
|
active_session_id.as_deref() == Some(session.id.as_str());
|
|
let name = session.name.clone();
|
|
let detail = self.session_detail(&session);
|
|
div()
|
|
.id(("saved-connect", ix))
|
|
.w_full()
|
|
.p_2()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(if is_active {
|
|
cx.theme().primary
|
|
} else {
|
|
cx.theme().border
|
|
})
|
|
.bg(if is_active {
|
|
cx.theme().tab_active
|
|
} else {
|
|
cx.theme().muted
|
|
})
|
|
.cursor_pointer()
|
|
.hover(|this| this.bg(cx.theme().secondary))
|
|
.on_mouse_down(
|
|
MouseButton::Left,
|
|
cx.listener(move |this, _, _, cx| {
|
|
this.connect_saved_session(connect_id.clone(), cx)
|
|
}),
|
|
)
|
|
.context_menu({
|
|
let view = cx.entity();
|
|
move |menu, window, _| {
|
|
let edit_value = edit_id.clone();
|
|
let delete_value = delete_id.clone();
|
|
menu.item(PopupMenuItem::new("Edit").on_click(
|
|
window.listener_for(
|
|
&view,
|
|
move |this, _, window, cx| {
|
|
this.edit_saved_session(
|
|
edit_value.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
},
|
|
),
|
|
))
|
|
.item(
|
|
PopupMenuItem::new("Delete").on_click(
|
|
window.listener_for(
|
|
&view,
|
|
move |this, _, _, cx| {
|
|
this.remove_saved_session(
|
|
delete_value.clone(),
|
|
cx,
|
|
)
|
|
},
|
|
),
|
|
),
|
|
)
|
|
}
|
|
})
|
|
.child(
|
|
v_flex()
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.child(name),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(rems(0.917))
|
|
.text_color(cx.theme().muted_foreground)
|
|
.child(detail),
|
|
),
|
|
)
|
|
})),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Render for Ashell {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
if self
|
|
.active_tab
|
|
.as_ref()
|
|
.is_some_and(|active_id| !self.tabs.iter().any(|tab| &tab.id == active_id))
|
|
{
|
|
self.active_tab = self.tabs.first().map(|tab| tab.id.clone());
|
|
self.remote_sample_in_flight = false;
|
|
self.request_active_system_snapshot();
|
|
}
|
|
self.sync_sftp_path_input(window, cx);
|
|
self.sync_terminal_size(window, cx);
|
|
if self.show_transfers_dialog {
|
|
self.show_transfers_dialog = false;
|
|
self.show_transfers_dialog(window, cx);
|
|
}
|
|
if let Some(new_display_offset) = self.terminal_scrollbar.future_display_offset.take() {
|
|
if let Some(active_id) = self.active_tab.clone() {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
|
let current = tab.render_snapshot().display_offset;
|
|
match new_display_offset.cmp(¤t) {
|
|
std::cmp::Ordering::Greater => {
|
|
tab.scroll_up_by(new_display_offset - current)
|
|
}
|
|
std::cmp::Ordering::Less => {
|
|
tab.scroll_down_by(current - new_display_offset)
|
|
}
|
|
std::cmp::Ordering::Equal => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let connection_color = self.active_connection_color(cx);
|
|
let terminal_snapshot = self.active_snapshot();
|
|
let active_tab_index = self
|
|
.active_tab
|
|
.as_ref()
|
|
.and_then(|active_id| self.tabs.iter().position(|tab| &tab.id == active_id));
|
|
if let Some(snapshot) = terminal_snapshot.as_ref() {
|
|
self.terminal_scrollbar
|
|
.update(snapshot, px(self.terminal_line_height()));
|
|
}
|
|
|
|
div()
|
|
.size_full()
|
|
.bg(cx.theme().background)
|
|
.text_color(cx.theme().foreground)
|
|
.font_family(self.ui_font_family.clone())
|
|
.child(
|
|
h_resizable("ashell-workspace")
|
|
.with_state(&self.workspace_panels)
|
|
.child(
|
|
resizable_panel()
|
|
.size(px(self.config.workspace_panels().and_then(|s| s.first().copied()).unwrap_or(SIDEBAR_WIDTH)))
|
|
.size_range(px(240.)..px(520.))
|
|
.flex_none()
|
|
.child(self.sidebar(cx)),
|
|
)
|
|
.child(
|
|
resizable_panel().child(
|
|
v_flex()
|
|
.size_full()
|
|
.relative()
|
|
.overflow_hidden()
|
|
.child(
|
|
v_flex()
|
|
.h(px(60.))
|
|
.w_full()
|
|
.flex_none()
|
|
.border_b_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().tab_bar)
|
|
.child(
|
|
h_flex()
|
|
.h(px(48.))
|
|
.w_full()
|
|
.items_center()
|
|
.gap_2()
|
|
.px_3()
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w(px(0.))
|
|
.h_full()
|
|
.overflow_x_scrollbar()
|
|
.child(
|
|
TabBar::new("ashell-tab-bar")
|
|
.selected_index(active_tab_index.unwrap_or(0))
|
|
.children(self.tabs.iter().enumerate().map(|(ix, tab)| {
|
|
let id = tab.id.clone();
|
|
let close_id = tab.id.clone();
|
|
Tab::new()
|
|
.label(tab.title.clone())
|
|
.on_click(cx.listener(move |this, _, window, cx| {
|
|
this.activate_tab(id.clone(), window, cx)
|
|
}))
|
|
.suffix(
|
|
Button::new(("tab-close", ix))
|
|
.ghost()
|
|
.xsmall()
|
|
.icon(IconName::Close)
|
|
.on_mouse_down(MouseButton::Left, |_, window, cx| {
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
})
|
|
.on_click(cx.listener(move |this, _, window, cx| {
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
this.close_tab(close_id.clone(), cx)
|
|
})),
|
|
)
|
|
}))
|
|
.last_empty_space(div().w_3())
|
|
.w_full()
|
|
.h_full(),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.flex_none()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(
|
|
Button::new("open-selector")
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Plus)
|
|
.on_click(cx.listener(|this, _, window, cx| this.show_selector_dialog(window, cx))),
|
|
)
|
|
.child(
|
|
Button::new("open-settings")
|
|
.ghost()
|
|
.small()
|
|
.icon(IconName::Settings2)
|
|
.on_click(cx.listener(|this, _, window, cx| this.show_settings_dialog(window, cx))),
|
|
)
|
|
.child(self.theme_dropdown(cx))
|
|
.child(
|
|
div()
|
|
.w(px(10.))
|
|
.h(px(10.))
|
|
.rounded_full()
|
|
.bg(connection_color),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
v_resizable("ashell-main-panels")
|
|
.with_state(&self.body_panels)
|
|
.child(
|
|
resizable_panel().child(
|
|
div()
|
|
.size_full()
|
|
.p_4()
|
|
.bg(cx.theme().background)
|
|
.text_color(cx.theme().foreground)
|
|
.font_family(self.ui_font_family.clone())
|
|
.text_size(rems(1.083))
|
|
.line_height(px(18.))
|
|
.overflow_hidden()
|
|
.track_focus(&self.focus_handle)
|
|
.key_context(TERMINAL_KEY_CONTEXT)
|
|
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
|
|
.on_mouse_down(MouseButton::Right, cx.listener(Self::on_terminal_right_click))
|
|
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
|
|
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
|
|
.on_key_down(cx.listener(Self::on_terminal_key_down))
|
|
.on_action(cx.listener(Self::on_terminal_tab_action))
|
|
.on_action(cx.listener(Self::on_terminal_backtab_action))
|
|
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
|
|
.child(match terminal_snapshot.clone() {
|
|
None => self.render_home_page(cx).into_any_element(),
|
|
Some(snapshot) => h_flex()
|
|
.w_full()
|
|
.h_full()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.size_full()
|
|
.on_prepaint({
|
|
let view = cx.entity().clone();
|
|
move |bounds, _window, cx| {
|
|
let _ = view.update(cx, |this, _| {
|
|
this.terminal_bounds = Some(bounds);
|
|
});
|
|
}
|
|
})
|
|
.child(
|
|
TerminalElement::new(
|
|
cx.entity(),
|
|
self.focus_handle.clone(),
|
|
snapshot,
|
|
self.terminal_marked_text.clone(),
|
|
self.terminal_font_family.clone(),
|
|
px(self.terminal_font_size),
|
|
px(self.terminal_line_height()),
|
|
px(self.terminal_cell_width()),
|
|
),
|
|
)
|
|
.vertical_scrollbar(&self.terminal_scrollbar)
|
|
.into_any_element(),
|
|
)
|
|
.into_any_element(),
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
resizable_panel()
|
|
.size(px(self.config.body_panels().and_then(|s| s.get(1).copied()).unwrap_or(248.0)))
|
|
.size_range(px(180.)..px(1000.))
|
|
.child(self.render_sftp_panel(window, cx)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.children(Root::render_dialog_layer(window, cx))
|
|
|
|
.children(Root::render_sheet_layer(window, cx))
|
|
.when_some(self.sftp_context_menu.clone(), |this, menu| {
|
|
let label = if menu.is_dir {
|
|
"Download Folder"
|
|
} else {
|
|
"Download"
|
|
};
|
|
this.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.left_0()
|
|
.right_0()
|
|
.bottom_0()
|
|
.on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, cx| {
|
|
this.dismiss_sftp_context_menu(cx);
|
|
}))
|
|
.on_mouse_down(MouseButton::Right, cx.listener(|this, _, _, cx| {
|
|
this.dismiss_sftp_context_menu(cx);
|
|
}))
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.left(menu.position.x)
|
|
.top(menu.position.y)
|
|
.w(px(172.))
|
|
.p_1()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().popover)
|
|
.shadow_lg()
|
|
.on_mouse_down(MouseButton::Left, |_, window, cx| {
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
})
|
|
.on_mouse_down(MouseButton::Right, |_, window, cx| {
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
})
|
|
.child(
|
|
Button::new("sftp-context-download")
|
|
.ghost()
|
|
.w_full()
|
|
.justify_start()
|
|
.label(label)
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.trigger_sftp_context_download(window, cx);
|
|
})),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
.when_some(self.connection_progress.clone(), |this, progress| {
|
|
this.child(
|
|
div()
|
|
.absolute()
|
|
.top_0()
|
|
.left_0()
|
|
.right_0()
|
|
.bottom_0()
|
|
.bg(Hsla {
|
|
h: 0.0,
|
|
s: 0.0,
|
|
l: 0.0,
|
|
a: 0.48,
|
|
})
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.child(
|
|
div()
|
|
.w(px(420.))
|
|
.p_5()
|
|
.rounded_lg()
|
|
.border_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().popover)
|
|
.shadow_lg()
|
|
.child(
|
|
v_flex()
|
|
.gap_4()
|
|
.child(
|
|
Button::new("ssh-connect-progress")
|
|
.primary()
|
|
.loading(!progress.failed)
|
|
.label(progress.title.clone()),
|
|
)
|
|
.child(
|
|
div()
|
|
.max_h(px(220.))
|
|
.overflow_y_scrollbar()
|
|
.child(
|
|
v_flex()
|
|
.gap_2()
|
|
.children(progress.lines.iter().cloned().map(|line| {
|
|
div()
|
|
.text_size(rems(1.0))
|
|
.text_color(if progress.failed {
|
|
cx.theme().danger
|
|
} else {
|
|
cx.theme().muted_foreground
|
|
})
|
|
.child(line)
|
|
})),
|
|
),
|
|
)
|
|
.when(progress.failed, |this| {
|
|
this.child(
|
|
h_flex()
|
|
.justify_end()
|
|
.gap_2()
|
|
.child(
|
|
Button::new("ssh-connect-progress-retry")
|
|
.primary()
|
|
.label(t!("retry").to_string())
|
|
.on_click(cx.listener(|this, _, _, cx| {
|
|
this.retry_connection_progress(cx)
|
|
})),
|
|
)
|
|
.child(
|
|
Button::new("ssh-connect-progress-close")
|
|
.label(t!("cancel").to_string())
|
|
.on_click(cx.listener(|this, _, _, cx| {
|
|
this.cancel_connection_progress(cx)
|
|
})),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
fn load_fonts(cx: &mut App) -> Result<()> {
|
|
let regular =
|
|
Cow::Borrowed(include_bytes!("../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice());
|
|
let bold = 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")?;
|
|
// At startup we don't have a config yet, so pass the default UI font family.
|
|
// It will be reapplied in Ashell::new() -> apply_theme_preferences.
|
|
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
|
|
Ok(())
|
|
}
|
|
|
|
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:#}");
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn sync_macos_launch_environment() {
|
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
|
let Ok(output) = 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"))]
|
|
fn sync_macos_launch_environment() {}
|
|
|
|
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::config::SavedWindowBounds::Fullscreen {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
} => gpui::WindowBounds::Fullscreen(Bounds::new(
|
|
point(px(*x), px(*y)),
|
|
size(px(*width), px(*height)),
|
|
)),
|
|
crate::config::SavedWindowBounds::Maximized {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
} => gpui::WindowBounds::Maximized(Bounds::new(
|
|
point(px(*x), px(*y)),
|
|
size(px(*width), px(*height)),
|
|
)),
|
|
crate::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");
|
|
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::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::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::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");
|
|
}
|
|
|
|
fn main() {
|
|
sync_macos_launch_environment();
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
)
|
|
.init();
|
|
|
|
#[cfg(target_os = "macos")]
|
|
let app = gpui_platform::application()
|
|
.with_assets(Assets)
|
|
.with_quit_mode(QuitMode::Explicit);
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
let app = gpui_platform::application().with_assets(Assets);
|
|
app.on_reopen(|cx| {
|
|
if cx.windows().is_empty() {
|
|
open_main_window(cx);
|
|
}
|
|
});
|
|
app.run(move |cx| {
|
|
gpui_component::init(cx);
|
|
cx.bind_keys([
|
|
KeyBinding::new("tab", TerminalTabKey, Some(TERMINAL_KEY_CONTEXT)),
|
|
KeyBinding::new("shift-tab", TerminalBacktabKey, Some(TERMINAL_KEY_CONTEXT)),
|
|
]);
|
|
load_embedded_themes(cx);
|
|
if let Err(err) = load_fonts(cx) {
|
|
tracing::warn!("failed to load embedded fonts: {err:#}");
|
|
}
|
|
open_main_window(cx);
|
|
});
|
|
}
|