From 4607b2c9957c99229a509e4e0c1c5a578eac694b Mon Sep 17 00:00:00 2001 From: Realm Date: Sat, 22 Aug 2026 22:00:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=20UI=20=E4=B8=8E=E5=AF=B9=E8=AF=9D=E6=A1=86=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 拆分终端与会话基础能力 * feat: 拆分应用状态与布局基础 * feat: 拆分工作区 UI 与对话框基础 --- src/app/dialogs.rs | 2171 ++++++++++++++++++++++--- src/app/ui.rs | 3774 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 4877 insertions(+), 1068 deletions(-) diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 2388722..6a4955f 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -1,23 +1,41 @@ use gpui::{ - Anchor, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton, - ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, - prelude::FluentBuilder as _, px, rems, + Anchor, AppContext as _, Bounds, Context, DragMoveEvent, Empty, Focusable as _, FontWeight, + InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, + Point, Render, SharedString, Size, StatefulInteractiveElement as _, Styled as _, Window, div, + point, prelude::FluentBuilder as _, px, rems, size, }; use gpui_component::{ - ActiveTheme as _, Disableable as _, IconName, Sizable as _, WindowExt as _, - button::{Button, ButtonVariants as _}, + ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, WindowExt as _, + button::ButtonVariants as _, dialog::Dialog, h_flex, input::Input, menu::{DropdownMenu as _, PopupMenuItem}, progress::Progress, scroll::{Scrollbar, ScrollbarShow}, - switch::Switch, v_flex, }; use rust_i18n::t; -use crate::{Ashell, session::config::AuthMethod, system::format_bytes}; +use crate::{ + Ashell, + app::controls::{pointer_button, pointer_switch}, + session::config::AuthMethod, + system::{RemoteProcess, format_bytes}, + text_encoding::{FILE_ENCODINGS, TERMINAL_ENCODINGS, TextEncoding}, +}; + +#[derive(Clone)] +enum SftpEditorDrag { + Move, + Resize, +} + +impl Render for SftpEditorDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} impl Ashell { pub(crate) fn show_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context) { @@ -35,6 +53,7 @@ impl Ashell { } let initial_is_serial = self.session_protocol == "serial"; + let initial_is_editing = self.editing_session_id.is_some(); let view = cx.entity(); let session_name_input = self.session_name_input.clone(); let host_input = self.host_input.clone(); @@ -53,7 +72,12 @@ impl Ashell { window.open_dialog(cx, move |dialog: Dialog, _window, _cx| { dialog - .title(if initial_is_serial { t!("new_serial_connection") } else { t!("new_ssh_connection") }) + .title(match (initial_is_serial, initial_is_editing) { + (true, true) => t!("edit_serial_connection"), + (true, false) => t!("new_serial_connection"), + (false, true) => t!("edit_connection"), + (false, false) => t!("new_connection"), + }) .w(px(520.)) .overlay_closable(true) .on_close({ @@ -91,6 +115,7 @@ impl Ashell { let protocol = view.read(cx).session_protocol.clone(); let is_ssh = protocol == "ssh"; let is_serial = protocol == "serial"; + let terminal_encoding = view.read(cx).ssh_terminal_encoding; content.child( v_flex() .gap_3() @@ -98,7 +123,7 @@ impl Ashell { h_flex() .gap_2() .child( - Button::new("proto-ssh") + pointer_button("proto-ssh") .label("SSH") .when(is_ssh, |button| button.primary()) .on_click(window.listener_for( @@ -110,7 +135,7 @@ impl Ashell { )), ) .child( - Button::new("proto-serial") + pointer_button("proto-serial") .label("Serial") .when(is_serial, |button| button.primary()) .on_click(window.listener_for( @@ -146,7 +171,7 @@ impl Ashell { h_flex() .gap_2() .child( - Button::new("ssh-auth-password") + pointer_button("ssh-auth-password") .label(t!("password").to_string()) .when(is_password, |button| button.primary()) .on_click(window.listener_for( @@ -160,7 +185,7 @@ impl Ashell { )), ) .child( - Button::new("ssh-auth-key") + pointer_button("ssh-auth-key") .label(t!("key").to_string()) .when(is_key, |button| button.primary()) .on_click(window.listener_for( @@ -174,7 +199,7 @@ impl Ashell { )), ) .child( - Button::new("ssh-auth-config") + pointer_button("ssh-auth-config") .label(t!("ssh_config").to_string()) .when(is_config, |button| button.primary()) .on_click(window.listener_for( @@ -190,22 +215,47 @@ impl Ashell { ) .when(!is_config, |this| { this.child(Input::new(&session_name_input).tab_index(0)) - .child(Input::new(&host_input).tab_index(1)) .child( h_flex() + .w_full() .gap_2() .child( - Input::new(&port_input).w(px(96.)).tab_index(2), + Input::new(&host_input) + .flex_1() + .min_w(px(0.)) + .tab_index(1), ) .child( - Input::new(&user_input).flex_1().tab_index(3), + Input::new(&port_input) + .w(px(96.)) + .tab_index(2), ), ) - }) - .when(is_password, |this| { - this.child( - Input::new(&password_input).mask_toggle().tab_index(4), - ) + .when(is_password, |this| { + this.child( + h_flex() + .w_full() + .gap_2() + .child( + Input::new(&user_input) + .flex_1() + .min_w(px(0.)) + .tab_index(3), + ) + .child( + Input::new(&password_input) + .flex_1() + .min_w(px(0.)) + .mask_toggle() + .tab_index(4), + ), + ) + }) + .when(is_key, |this| { + this.child( + Input::new(&user_input).w_full().tab_index(3), + ) + }) }) .when(is_key, |this| { this.child( @@ -229,7 +279,7 @@ impl Ashell { ), ) .child( - Button::new("clear-key-path") + pointer_button("clear-key-path") .ghost() .icon(IconName::Close) .on_click(window.listener_for( @@ -318,6 +368,65 @@ impl Ashell { ) } }) + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .text_sm() + .font_weight(FontWeight::BOLD) + .child(t!("terminal_encoding").to_string()), + ) + .child( + pointer_button("ssh-terminal-encoding") + .ghost() + .icon(IconName::Globe) + .label(terminal_encoding.label()) + .dropdown_menu_with_anchor( + Anchor::BottomRight, + { + let view = view.clone(); + move |menu, window, _| { + TERMINAL_ENCODINGS + .iter() + .copied() + .fold( + menu.min_w(0.), + |menu, candidate| { + menu.item( + PopupMenuItem::new( + candidate + .label(), + ) + .checked( + candidate + == terminal_encoding, + ) + .on_click( + window.listener_for( + &view, + move |this, + _, + _, + cx| { + this.set_ssh_terminal_encoding( + candidate, + cx, + ); + }, + ), + ), + ) + }, + ) + } + }, + ), + ), + ) .when(!is_config, |this| { this.child( div() @@ -329,7 +438,7 @@ impl Ashell { h_flex() .gap_2() .child( - Button::new("proxy-none") + pointer_button("proxy-none") .label(t!("proxy_none").to_string()) .when(proxy_type == "none", |button| { button.primary() @@ -345,7 +454,7 @@ impl Ashell { )), ) .child( - Button::new("proxy-socks5") + pointer_button("proxy-socks5") .label("SOCKS5") .when(proxy_type == "socks5", |button| { button.primary() @@ -361,7 +470,7 @@ impl Ashell { )), ) .child( - Button::new("proxy-http") + pointer_button("proxy-http") .label("HTTP") .when(proxy_type == "http", |button| { button.primary() @@ -405,7 +514,7 @@ impl Ashell { .justify_end() .gap_2() .child( - Button::new("connect-ssh-cancel") + pointer_button("connect-ssh-cancel") .label(t!("cancel").to_string()) .on_click(window.listener_for( &view, @@ -418,7 +527,7 @@ impl Ashell { ) .when(!is_config, |this| { this.child( - Button::new("connect-ssh-confirm") + pointer_button("connect-ssh-confirm") .primary() .label(if is_editing { t!("save") @@ -572,7 +681,7 @@ impl Ashell { div() .text_size(rems(1.0)) .font_weight(FontWeight::SEMIBOLD) - .child(t!("new_ssh_connection")), + .child(t!("new_connection")), ) .child( div() @@ -736,7 +845,7 @@ impl Ashell { let clear_btn = if can_clear { Some( - Button::new("clear_transfers_btn") + pointer_button("clear_transfers_btn") .small() .ghost() .icon(IconName::Delete) @@ -780,7 +889,7 @@ impl Ashell { ) .child( h_flex().gap_2().children(clear_btn).child( - Button::new("close_dialog") + pointer_button("close_dialog") .small() .ghost() .icon(IconName::Close) @@ -823,190 +932,182 @@ impl Ashell { } }; - 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 { - match t.info.kind { - crate::terminal::TransferType::Upload => { - format!("{}...", t!("uploading")) + 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 { + match t.info.kind { + crate::terminal::TransferType::Upload => { + format!("{}...", t!("uploading")) + } + crate::terminal::TransferType::Download => { + format!("{}...", t!("downloading")) + } } - crate::terminal::TransferType::Download => { - 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(); - move |this, _, _, _| { - if let Some(handle) = this.active_sftp_handle() { - 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(); - move |this, _, _, _| { - if let Some(handle) = this.active_sftp_handle() { - 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(); - move |this, _, _, _| { - if let Some(handle) = this.active_sftp_handle() { - 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(); - move |this, _, _, _| { - if let Some(handle) = this.active_sftp_handle() { - handle.cancel_transfer(id.clone()); - } - } - })); - (txt, h_flex().gap_1().child(btn_resume).child(btn_cancel)) - } - crate::terminal::TransferState::Interrupted(ref reason) => { - let txt = format!("{}: {}", t!("interrupted"), reason); - let btn_remove = Button::new(SharedString::from(format!( - "remove-{}", - t.info.id - ))) - .ghost() - .small() - .icon(IconName::Close) - .on_click(window.listener_for(&view, { - let id = t.info.id.clone(); - move |this, _, _, cx| { - this.remove_transfer(&id, cx); - } - })); - (txt, h_flex().gap_1().child(btn_remove)) - } - 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 - ))) + }; + let btn_pause = pointer_button(SharedString::from( + format!("pause-{}", 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(); + .icon(IconName::Pause) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, _| { + if let Some(handle) = this.active_sftp_handle() { + handle.pause_transfer(id.clone()); + } } - }); - actions = actions.child(btn_folder); + })); + let btn_cancel = pointer_button(SharedString::from( + format!("cancel-{}", t.info.id), + )) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, _| { + if let Some(handle) = this.active_sftp_handle() { + handle.cancel_transfer(id.clone()); + } + } + })); + (txt, h_flex().gap_1().child(btn_pause).child(btn_cancel)) } - let btn_remove = Button::new(SharedString::from(format!( - "remove-{}", - t.info.id - ))) - .ghost() - .small() - .icon(IconName::Close) - .on_click(window.listener_for(&view, { - let id = t.info.id.clone(); - move |this, _, _, cx| { - this.remove_transfer(&id, cx); + crate::terminal::TransferState::Paused => { + let txt = t!("paused").to_string(); + let btn_resume = pointer_button(SharedString::from( + format!("resume-{}", t.info.id), + )) + .ghost() + .small() + .icon(IconName::Play) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, _| { + if let Some(handle) = this.active_sftp_handle() { + handle.resume_transfer(id.clone()); + } + } + })); + let btn_cancel = pointer_button(SharedString::from( + format!("cancel-{}", t.info.id), + )) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, _| { + if let Some(handle) = this.active_sftp_handle() { + handle.cancel_transfer(id.clone()); + } + } + })); + (txt, h_flex().gap_1().child(btn_resume).child(btn_cancel)) + } + crate::terminal::TransferState::Interrupted(ref reason) => { + let txt = format!("{}: {}", t!("interrupted"), reason); + let btn_remove = pointer_button(SharedString::from( + format!("remove-{}", t.info.id), + )) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, cx| { + this.remove_transfer(&id, cx); + } + })); + (txt, h_flex().gap_1().child(btn_remove)) + } + 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 = pointer_button(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); } - })); - actions = actions.child(btn_remove); - (txt, actions) - } - crate::terminal::TransferState::Failed(ref err) => { - let txt = format!("{}: {}", t!("failed"), err); - let btn_remove = Button::new(SharedString::from(format!( - "remove-{}", - t.info.id - ))) - .ghost() - .small() - .icon(IconName::Close) - .on_click(window.listener_for(&view, { - let id = t.info.id.clone(); - move |this, _, _, cx| { - this.remove_transfer(&id, cx); - } - })); - (txt, h_flex().gap_1().child(btn_remove)) - } - crate::terminal::TransferState::Zombie(ref reason) => { - let txt = format!("{}: {}", t!("zombie"), reason); - let btn_remove = Button::new(SharedString::from(format!( - "remove-{}", - t.info.id - ))) - .ghost() - .small() - .icon(IconName::Close) - .on_click(window.listener_for(&view, { - let id = t.info.id.clone(); - move |this, _, _, cx| { - this.remove_transfer(&id, cx); - } - })); - (txt, h_flex().gap_1().child(btn_remove)) - } - }; + let btn_remove = pointer_button(SharedString::from( + format!("remove-{}", t.info.id), + )) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, cx| { + this.remove_transfer(&id, cx); + } + })); + actions = actions.child(btn_remove); + (txt, actions) + } + crate::terminal::TransferState::Failed(ref err) => { + let txt = format!("{}: {}", t!("failed"), err); + let btn_remove = pointer_button(SharedString::from( + format!("remove-{}", t.info.id), + )) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, cx| { + this.remove_transfer(&id, cx); + } + })); + (txt, h_flex().gap_1().child(btn_remove)) + } + crate::terminal::TransferState::Zombie(ref reason) => { + let txt = format!("{}: {}", t!("zombie"), reason); + let btn_remove = pointer_button(SharedString::from( + format!("remove-{}", t.info.id), + )) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, cx| { + this.remove_transfer(&id, cx); + } + })); + (txt, h_flex().gap_1().child(btn_remove)) + } + }; let percent = match t.state { crate::terminal::TransferState::Completed => 100.0, @@ -1028,7 +1129,7 @@ impl Ashell { .items_center() .gap_2() .child( - Button::new(SharedString::from(format!( + pointer_button(SharedString::from(format!( "icon-{}", t.info.id ))) @@ -1288,7 +1389,7 @@ impl Ashell { .justify_end() .gap_2() .child( - Button::new("cancel") + pointer_button("cancel") .ghost() .label(t!("cancel").to_string()) .on_click(move |_, window, cx| { @@ -1296,7 +1397,7 @@ impl Ashell { }), ) .child( - Button::new("confirm") + pointer_button("confirm") .danger() .label(t!("confirm").to_string()) .on_click({ @@ -1322,6 +1423,1551 @@ impl Ashell { }) }); } + + pub(crate) fn show_terminate_process_dialog( + &mut self, + tab_id: String, + process: RemoteProcess, + window: &mut Window, + cx: &mut Context, + ) { + if process.pid <= 1 + || self.system_tab_id.as_deref() != Some(tab_id.as_str()) + || !self.tabs.iter().any(|tab| { + tab.id == tab_id && tab.kind == crate::terminal::TabKind::Ssh && tab.connected + }) + { + return; + } + + let view = cx.entity(); + let pid = process.pid; + let process_name = process.command; + let process_user = process.user; + + window.open_dialog(cx, move |dialog: Dialog, _window, _| { + dialog + .title(t!("confirm_terminate_process").to_string()) + .w(px(480.)) + .keyboard(false) + .content({ + let process_name = process_name.clone(); + let process_user = process_user.clone(); + move |content, _window, cx| { + content.child( + v_flex() + .w_full() + .gap_3() + .child( + div() + .w_full() + .whitespace_normal() + .line_clamp(3) + .text_size(rems(0.917)) + .child( + t!("confirm_terminate_process_desc", pid = pid) + .to_string(), + ), + ) + .child( + v_flex() + .w_full() + .gap_1() + .p_3() + .rounded_md() + .bg(cx.theme().muted) + .child( + div() + .w_full() + .truncate() + .text_size(rems(0.833)) + .font_weight(FontWeight::SEMIBOLD) + .child(process_name.clone()), + ) + .child( + div() + .text_size(rems(0.75)) + .text_color(cx.theme().muted_foreground) + .child( + t!( + "process_summary", + name = process_user.as_str(), + pid = pid + ) + .to_string(), + ), + ), + ), + ) + } + }) + .footer({ + let view = view.clone(); + let tab_id = tab_id.clone(); + h_flex() + .w_full() + .justify_end() + .gap_2() + .child( + pointer_button("terminate-process-cancel") + .ghost() + .label(t!("cancel").to_string()) + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + pointer_button("terminate-process-confirm") + .danger() + .label(t!("terminate_process").to_string()) + .on_click(move |_, window, cx| { + view.update(cx, |this, cx| { + this.terminate_remote_process(tab_id.clone(), pid, cx); + }); + window.close_dialog(cx); + }), + ) + }) + }); + } + + pub(crate) fn prompt_active_ssh_reconnect_if_needed( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab_id) = self.active_tab.as_ref().and_then(|active_tab_id| { + self.tabs + .iter() + .find(|tab| { + tab.id == *active_tab_id + && tab.kind == crate::terminal::TabKind::Ssh + && !tab.connected + && tab.disconnected_reason.is_some() + && tab.session.is_some() + }) + .map(|tab| tab.id.clone()) + }) else { + return; + }; + + self.show_ssh_reconnect_dialog(tab_id, window, cx); + } + + pub(crate) fn show_ssh_reconnect_dialog( + &mut self, + tab_id: String, + window: &mut Window, + cx: &mut Context, + ) { + if self.active_dialog.is_some() + || self.active_tab.as_deref() != Some(tab_id.as_str()) + || !self.tabs.iter().any(|tab| { + tab.id == tab_id + && tab.kind == crate::terminal::TabKind::Ssh + && !tab.connected + && tab.disconnected_reason.is_some() + && tab.session.is_some() + }) + { + return; + } + + let session_name = self + .tabs + .iter() + .find(|tab| tab.id == tab_id) + .map(|tab| tab.title.clone()) + .unwrap_or_else(|| tab_id.clone()); + let view = cx.entity(); + self.active_dialog = Some(crate::app::DialogKind::SshReconnect); + + window.open_dialog(cx, move |dialog: Dialog, _window, _| { + dialog + .title(t!("reconnect_ssh_title").to_string()) + .w(px(400.)) + .keyboard(false) + .on_close({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if this.active_dialog == Some(crate::app::DialogKind::SshReconnect) { + this.active_dialog = None; + } + cx.notify(); + }); + } + }) + .content({ + let session_name = session_name.clone(); + move |content, _window, _cx| { + content.child( + div() + .w_full() + .whitespace_normal() + .text_size(rems(0.917)) + .child( + t!("reconnect_ssh_desc", name = session_name.as_str()) + .to_string(), + ), + ) + } + }) + .footer({ + let view = view.clone(); + let tab_id = tab_id.clone(); + h_flex() + .w_full() + .justify_end() + .gap_2() + .child( + pointer_button("ssh-reconnect-cancel") + .ghost() + .label(t!("cancel").to_string()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + if this.active_dialog + == Some(crate::app::DialogKind::SshReconnect) + { + this.active_dialog = None; + } + cx.notify(); + }); + window.close_dialog(cx); + } + }), + ) + .child( + pointer_button("ssh-reconnect-confirm") + .primary() + .label(t!("reconnect_ssh").to_string()) + .on_click(move |_, window, cx| { + view.update(cx, |this, cx| { + if this.active_dialog + == Some(crate::app::DialogKind::SshReconnect) + { + this.active_dialog = None; + } + this.retry_disconnected_tab(&tab_id, cx); + }); + window.close_dialog(cx); + }), + ) + }) + }); + } + + fn active_sftp_dialog_target(&self) -> Option<(String, crate::sftp::SftpHandle)> { + let group_id = self.active_group.clone()?; + let handle = self.sftp_handles.get(&group_id)?.clone(); + Some((group_id, handle)) + } + + pub(crate) fn show_sftp_rename_dialog( + &mut self, + remote_path: String, + window: &mut Window, + cx: &mut Context, + ) { + if self.active_dialog.is_some() { + return; + } + let Some((group_id, _)) = self.active_sftp_dialog_target() else { + return; + }; + + self.active_dialog = Some(crate::app::DialogKind::SftpRename); + self.sftp_rename_state = Some(crate::app::SftpRenameState { + group_id, + old_path: remote_path.clone(), + in_flight: false, + error: None, + }); + Self::set_input_value( + &self.sftp_rename_input, + crate::sftp::base_name(&remote_path), + window, + cx, + ); + + let view = cx.entity(); + let rename_input = self.sftp_rename_input.clone(); + let focus_input = rename_input.clone(); + window.open_dialog(cx, move |dialog: Dialog, _window, _| { + dialog + .title(t!("rename").to_string()) + .w(px(440.)) + .close_button(false) + .keyboard(false) + .overlay_closable(false) + .on_close({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if this.active_dialog == Some(crate::app::DialogKind::SftpRename) { + this.active_dialog = None; + this.sftp_rename_state = None; + } + cx.notify(); + }); + } + }) + .content({ + let view = view.clone(); + let rename_input = rename_input.clone(); + move |content, window, cx| { + let state = view.read(cx).sftp_rename_state.clone(); + let new_name = rename_input.read(cx).value().trim().to_string(); + let name_error = if new_name.is_empty() { + Some(t!("name_required").to_string()) + } else if matches!(new_name.as_str(), "." | "..") + || new_name.contains('/') + || new_name.contains('\0') + { + Some(t!("invalid_remote_name").to_string()) + } else { + None + }; + let in_flight = state.as_ref().is_some_and(|state| state.in_flight); + let unchanged = state.as_ref().is_some_and(|state| { + crate::sftp::base_name(&state.old_path) == new_name + }); + let error = state.and_then(|state| state.error); + + content.child( + v_flex() + .gap_3() + .child( + v_flex() + .gap_1() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("new_name").to_string()), + ) + .child(Input::new(&rename_input).w_full().tab_index(0)), + ) + .when_some(name_error.or(error), |this, message| { + this.child( + div() + .text_sm() + .text_color(cx.theme().danger) + .child(message), + ) + }) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + pointer_button("sftp-rename-cancel") + .label(t!("cancel").to_string()) + .disabled(in_flight) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + if this + .sftp_rename_state + .as_ref() + .is_some_and(|state| state.in_flight) + { + return; + } + this.active_dialog = None; + this.sftp_rename_state = None; + window.close_dialog(cx); + cx.notify(); + }, + )), + ) + .child( + pointer_button("sftp-rename-save") + .primary() + .label(t!("rename").to_string()) + .loading(in_flight) + .disabled( + in_flight + || unchanged + || new_name.is_empty() + || new_name == "." + || new_name == ".." + || new_name.contains('/') + || new_name.contains('\0'), + ) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + this.submit_sftp_rename(window, cx); + }, + )), + ), + ), + ) + } + }) + }); + window.defer(cx, move |window, cx| { + focus_input.read(cx).focus_handle(cx).focus(window, cx); + }); + } + + pub(crate) fn submit_sftp_rename(&mut self, window: &mut Window, cx: &mut Context) { + let Some(state) = self.sftp_rename_state.clone() else { + return; + }; + if state.in_flight { + return; + } + + let new_name = self.sftp_rename_input.read(cx).value().trim().to_string(); + if new_name.is_empty() + || matches!(new_name.as_str(), "." | "..") + || new_name.contains('/') + || new_name.contains('\0') + { + return; + } + let parent = crate::sftp::parent_dir(&state.old_path).unwrap_or_else(|| "/".to_string()); + let new_path = crate::sftp::join_remote(&parent, &new_name); + if new_path == state.old_path { + return; + } + let Some(handle) = self.sftp_handles.get(&state.group_id).cloned() else { + if let Some(state) = self.sftp_rename_state.as_mut() { + state.error = Some(t!("sftp_connection_unavailable").to_string()); + } + cx.notify(); + return; + }; + + if let Some(state) = self.sftp_rename_state.as_mut() { + state.in_flight = true; + state.error = None; + } + cx.notify(); + + let group_id = state.group_id; + let old_path = state.old_path; + let response = handle.rename_path(old_path.clone(), new_path); + cx.spawn_in(window, async move |this, cx| { + let result = response + .await + .unwrap_or_else(|_| Err(t!("sftp_connection_unavailable").to_string())); + match result { + Ok(()) => { + let _ = this.update_in(cx, |this, window, cx| { + let is_current = this.active_dialog + == Some(crate::app::DialogKind::SftpRename) + && this.sftp_rename_state.as_ref().is_some_and(|state| { + state.group_id == group_id && state.old_path == old_path + }); + if is_current { + this.active_dialog = None; + this.sftp_rename_state = None; + this.status = t!("rename_success", name = new_name).to_string().into(); + window.close_dialog(cx); + cx.notify(); + } + }); + } + Err(error) => { + let _ = this.update(cx, |this, cx| { + if let Some(state) = this.sftp_rename_state.as_mut().filter(|state| { + state.group_id == group_id && state.old_path == old_path + }) { + state.in_flight = false; + state.error = Some(t!("rename_failed", err = error).to_string()); + cx.notify(); + } + }); + } + } + Ok::<(), anyhow::Error>(()) + }) + .detach(); + } + + fn initial_sftp_editor_bounds(viewport: Size) -> Bounds { + let margin = px(16.); + let available_width = (viewport.width - margin * 2.).max(px(1.)); + let available_height = (viewport.height - margin * 2.).max(px(1.)); + let width = px(880.).min(available_width); + let height = px(640.).min(available_height); + + Bounds { + origin: point( + ((viewport.width - width) / 2.).max(margin), + ((viewport.height - height) / 2.).max(margin), + ), + size: size(width, height), + } + } + + fn clamp_sftp_editor_bounds( + mut bounds: Bounds, + viewport: Size, + ) -> Bounds { + let margin = px(16.); + let max_width = (viewport.width - margin * 2.).max(px(1.)); + let max_height = (viewport.height - margin * 2.).max(px(1.)); + let min_width = px(640.).min(max_width); + let min_height = px(420.).min(max_height); + + bounds.size.width = bounds.size.width.clamp(min_width, max_width); + bounds.size.height = bounds.size.height.clamp(min_height, max_height); + let max_x = (viewport.width - margin - bounds.size.width).max(margin); + let max_y = (viewport.height - margin - bounds.size.height).max(margin); + bounds.origin.x = bounds.origin.x.clamp(margin, max_x); + bounds.origin.y = bounds.origin.y.clamp(margin, max_y); + bounds + } + + fn start_sftp_editor_move( + &mut self, + pointer_origin: Point, + viewport: Size, + cx: &mut Context, + ) { + let Some(state) = self.sftp_editor_state.as_mut() else { + return; + }; + state.bounds = Self::clamp_sftp_editor_bounds(state.bounds, viewport); + state.interaction = Some(crate::app::SftpEditorInteraction::Move { + pointer_origin, + initial_bounds: state.bounds, + }); + cx.notify(); + } + + fn start_sftp_editor_resize( + &mut self, + pointer_origin: Point, + viewport: Size, + cx: &mut Context, + ) { + let Some(state) = self.sftp_editor_state.as_mut() else { + return; + }; + state.bounds = Self::clamp_sftp_editor_bounds(state.bounds, viewport); + state.interaction = Some(crate::app::SftpEditorInteraction::Resize { + pointer_origin, + initial_bounds: state.bounds, + }); + cx.notify(); + } + + fn update_sftp_editor_interaction( + &mut self, + pointer: Point, + viewport: Size, + cx: &mut Context, + ) { + let Some(state) = self.sftp_editor_state.as_mut() else { + return; + }; + let Some(interaction) = state.interaction.clone() else { + return; + }; + + let mut bounds = match interaction { + crate::app::SftpEditorInteraction::Move { + pointer_origin, + initial_bounds, + } => Bounds { + origin: point( + initial_bounds.origin.x + pointer.x - pointer_origin.x, + initial_bounds.origin.y + pointer.y - pointer_origin.y, + ), + size: initial_bounds.size, + }, + crate::app::SftpEditorInteraction::Resize { + pointer_origin, + initial_bounds, + } => { + let margin = px(16.); + let max_width = (viewport.width - margin - initial_bounds.origin.x).max(px(1.)); + let max_height = (viewport.height - margin - initial_bounds.origin.y).max(px(1.)); + let min_width = px(640.).min(max_width); + let min_height = px(420.).min(max_height); + + Bounds { + origin: initial_bounds.origin, + size: size( + (initial_bounds.size.width + pointer.x - pointer_origin.x) + .clamp(min_width, max_width), + (initial_bounds.size.height + pointer.y - pointer_origin.y) + .clamp(min_height, max_height), + ), + } + } + }; + bounds = Self::clamp_sftp_editor_bounds(bounds, viewport); + if state.bounds != bounds { + state.bounds = bounds; + cx.notify(); + } + } + + fn finish_sftp_editor_interaction(&mut self, cx: &mut Context) { + if let Some(state) = self.sftp_editor_state.as_mut() + && state.interaction.take().is_some() + { + cx.notify(); + } + } + + pub(crate) fn show_sftp_editor_dialog( + &mut self, + remote_path: String, + window: &mut Window, + cx: &mut Context, + ) { + if self.active_dialog.is_some() { + return; + } + let Some((group_id, _)) = self.active_sftp_dialog_target() else { + return; + }; + let bounds = Self::initial_sftp_editor_bounds(window.viewport_size()); + let initial_encoding = self + .active_tab + .as_ref() + .and_then(|tab_id| self.tabs.iter().find(|tab| tab.id == *tab_id)) + .map(|tab| tab.text_encoding()) + .unwrap_or(TextEncoding::Utf8); + + self.active_dialog = Some(crate::app::DialogKind::SftpEditor); + self.sftp_editor_state = Some(crate::app::SftpEditorState { + group_id, + remote_path: remote_path.clone(), + raw_content: Vec::new(), + original_content: String::new(), + encoding: initial_encoding, + has_bom: false, + decode_had_errors: false, + loaded: false, + loading: true, + saving: false, + message: None, + error: None, + bounds, + interaction: None, + }); + self.sftp_editor_input.update(cx, |input, cx| { + input.set_highlighter(crate::sftp::editor_language(&remote_path), cx); + input.set_value("", window, cx); + }); + + let view = cx.entity(); + let editor_input = self.sftp_editor_input.clone(); + let title = format!( + "{} - {}", + t!("edit_file"), + crate::sftp::base_name(&remote_path) + ); + window.open_dialog(cx, move |dialog: Dialog, window, _| { + let viewport = window.viewport_size(); + + dialog + .w(viewport.width) + .h(viewport.height) + .margin_top(px(0.)) + .p_0() + .bg(gpui::transparent_black()) + .border_0() + .rounded_none() + .close_button(false) + .keyboard(true) + .overlay_closable(false) + .on_cancel({ + let view = view.clone(); + move |_, _, cx| { + !view + .read(cx) + .sftp_editor_state + .as_ref() + .is_some_and(|state| state.saving) + } + }) + .on_close({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if this.active_dialog == Some(crate::app::DialogKind::SftpEditor) { + this.active_dialog = None; + this.sftp_editor_state = None; + } + cx.notify(); + }); + } + }) + .content({ + let view = view.clone(); + let editor_input = editor_input.clone(); + let title = title.clone(); + move |content, window, cx| { + let viewport = window.viewport_size(); + let state = view.read(cx).sftp_editor_state.clone(); + let current_content = editor_input.read(cx).value().to_string(); + let (current_bytes, encode_had_errors) = state + .as_ref() + .map(|state| { + let (encoded, had_errors) = state + .encoding + .encode_file(¤t_content, state.has_bom); + (encoded.len(), had_errors) + }) + .unwrap_or((current_content.len(), false)); + let loaded = state.as_ref().is_some_and(|state| state.loaded); + let loading = state.as_ref().is_some_and(|state| state.loading); + let saving = state.as_ref().is_some_and(|state| state.saving); + let file_encoding = state + .as_ref() + .map(|state| state.encoding) + .unwrap_or(TextEncoding::Utf8); + let decode_had_errors = state + .as_ref() + .is_some_and(|state| state.decode_had_errors); + let dirty = state.as_ref().is_some_and(|state| { + state.loaded && state.original_content != current_content + }); + let over_limit = current_bytes > crate::sftp::MAX_INLINE_EDIT_BYTES; + let error = state.as_ref().and_then(|state| state.error.clone()); + let message = state.as_ref().and_then(|state| state.message.clone()); + let remote_path = state + .as_ref() + .map(|state| state.remote_path.clone()) + .unwrap_or_default(); + + let status = if loading { + t!("loading_file_content").to_string() + } else if saving { + t!("saving_file_content").to_string() + } else if over_limit { + t!("editor_content_too_large", max = "2 MB").to_string() + } else if encode_had_errors { + t!( + "encoding_encode_failed", + encoding = file_encoding.label() + ) + .to_string() + } else if let Some(error) = error.clone() { + error + } else if decode_had_errors { + t!( + "encoding_decode_warning", + encoding = file_encoding.label() + ) + .to_string() + } else if dirty { + t!("unsaved_changes").to_string() + } else if let Some(message) = message { + message + } else if loaded { + t!("file_content_loaded").to_string() + } else { + String::new() + }; + let status_color = if error.is_some() || over_limit || encode_had_errors { + cx.theme().danger + } else if dirty || decode_had_errors { + cx.theme().warning + } else if loaded && !loading && !saving { + cx.theme().success + } else { + cx.theme().muted_foreground + }; + let editor_bounds = state + .as_ref() + .map(|state| { + Ashell::clamp_sftp_editor_bounds(state.bounds, viewport) + }) + .unwrap_or_else(|| Ashell::initial_sftp_editor_bounds(viewport)); + let moving = state.as_ref().is_some_and(|state| { + matches!( + state.interaction.as_ref(), + Some(crate::app::SftpEditorInteraction::Move { .. }) + ) + }); + let resizing = state.as_ref().is_some_and(|state| { + matches!( + state.interaction.as_ref(), + Some(crate::app::SftpEditorInteraction::Resize { .. }) + ) + }); + + content.p_0().child( + div() + .id("sftp-editor-stage") + .size_full() + .relative() + .when(moving, |this| this.cursor_grabbing()) + .when(resizing, |this| this.cursor_nwse_resize()) + .on_mouse_up( + MouseButton::Left, + window.listener_for(&view, |this, _, _, cx| { + this.finish_sftp_editor_interaction(cx); + }), + ) + .on_mouse_up_out( + MouseButton::Left, + window.listener_for(&view, |this, _, _, cx| { + this.finish_sftp_editor_interaction(cx); + }), + ) + .child( + v_flex() + .absolute() + .left(editor_bounds.origin.x) + .top(editor_bounds.origin.y) + .w(editor_bounds.size.width) + .h(editor_bounds.size.height) + .min_h(px(0.)) + .overflow_hidden() + .occlude() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .shadow_xl() + .on_any_mouse_down(|_, _, cx| { + cx.stop_propagation(); + }) + .child( + h_flex() + .id("sftp-editor-title-bar") + .flex_none() + .h(px(40.)) + .px_3() + .items_center() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .bg(cx.theme().muted.opacity(0.8)) + .cursor_grab() + .on_mouse_down( + MouseButton::Left, + window.listener_for( + &view, + |this, + event: &MouseDownEvent, + window, + cx| { + this.start_sftp_editor_move( + event.position, + window.viewport_size(), + cx, + ); + window.prevent_default(); + cx.stop_propagation(); + }, + ), + ) + .on_drag( + SftpEditorDrag::Move, + |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }, + ) + .on_drag_move(window.listener_for( + &view, + |this, + event: &DragMoveEvent, + window, + cx| { + if matches!( + event.drag(cx), + SftpEditorDrag::Move + ) { + this.update_sftp_editor_interaction( + event.event.position, + window.viewport_size(), + cx, + ); + } + }, + )) + .on_mouse_up( + MouseButton::Left, + window.listener_for(&view, |this, _, _, cx| { + this.finish_sftp_editor_interaction(cx); + }), + ) + .on_mouse_up_out( + MouseButton::Left, + window.listener_for(&view, |this, _, _, cx| { + this.finish_sftp_editor_interaction(cx); + }), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .font_weight(FontWeight::SEMIBOLD) + .child(title.clone()), + ) + .child( + div() + .on_mouse_down( + MouseButton::Left, + |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }, + ) + .child( + pointer_button("sftp-editor-close") + .xsmall() + .ghost() + .icon(IconName::Close) + .tooltip(t!("cancel").to_string()) + .disabled(saving) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + if this + .sftp_editor_state + .as_ref() + .is_some_and(|state| { + state.saving + }) + { + return; + } + this.active_dialog = None; + this.sftp_editor_state = + None; + window.close_dialog(cx); + cx.notify(); + }, + )), + ), + ), + ) + .child( + v_flex() + .flex_1() + .min_h(px(0.)) + .gap_2() + .p_3() + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + div() + .id("sftp-editor-remote-path") + .flex_1() + .min_w(px(0.)) + .truncate() + .text_sm() + .text_color( + cx.theme().muted_foreground, + ) + .tooltip({ + let remote_path = + remote_path.clone(); + move |window, cx| { + gpui_component::tooltip::Tooltip::new( + remote_path.clone(), + ) + .build(window, cx) + } + }) + .child(remote_path), + ) + .child( + pointer_button( + "sftp-editor-encoding", + ) + .ghost() + .small() + .icon(IconName::Globe) + .label(file_encoding.label()) + .tooltip( + t!("file_encoding").to_string(), + ) + .disabled(loading || saving) + .dropdown_menu_with_anchor( + Anchor::BottomRight, + { + let view = view.clone(); + move |menu, window, _| { + FILE_ENCODINGS + .iter() + .copied() + .fold( + menu.min_w(0.), + |menu, candidate| { + menu.item( + PopupMenuItem::new( + candidate.label(), + ) + .checked( + candidate + == file_encoding, + ) + .on_click( + window.listener_for( + &view, + move |this, + _, + window, + cx| { + this.set_sftp_editor_encoding( + candidate, + window, + cx, + ); + }, + ), + ), + ) + }, + ) + } + }, + ), + ), + ) + .child( + div() + .flex_1() + .min_h(px(0.)) + .when( + loading + || (!loaded && error.is_some()), + |this| { + this.flex() + .items_center() + .justify_center() + }, + ) + .child(if loaded { + Input::new(&editor_input) + .size_full() + .tab_index(0) + .into_any_element() + } else { + v_flex() + .items_center() + .gap_3() + .child( + div() + .text_sm() + .text_color(status_color) + .child(status.clone()), + ) + .when(!loading, |this| { + this.child( + pointer_button( + "sftp-editor-retry", + ) + .icon(IconName::Redo) + .label( + t!("retry") + .to_string(), + ) + .on_click( + window.listener_for( + &view, + |this, + _, + window, + cx| { + this.load_sftp_editor_content(window, cx); + }, + ), + ), + ) + }) + .into_any_element() + }), + ) + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .text_sm() + .text_color(status_color) + .child(status), + ) + .when(loaded, |this| { + this.child( + div() + .text_sm() + .text_color( + cx.theme() + .muted_foreground, + ) + .child( + crate::system::format_bytes( + current_bytes as u64, + ), + ), + ) + }) + .child( + pointer_button("sftp-editor-cancel") + .label(t!("cancel").to_string()) + .disabled(saving) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + if this + .sftp_editor_state + .as_ref() + .is_some_and(|state| { + state.saving + }) + { + return; + } + this.active_dialog = None; + this.sftp_editor_state = + None; + window.close_dialog(cx); + cx.notify(); + }, + )), + ) + .child( + pointer_button("sftp-editor-save") + .primary() + .icon(IconName::Check) + .label(t!("save").to_string()) + .loading(saving) + .disabled( + !loaded + || !dirty + || saving + || over_limit + || decode_had_errors + || encode_had_errors, + ) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + this.save_sftp_editor_content( + window, cx, + ); + }, + )), + ), + ), + ) + .child( + div() + .id("sftp-editor-resize-handle") + .absolute() + .right_0() + .bottom_0() + .size(px(18.)) + .cursor_nwse_resize() + .on_mouse_down( + MouseButton::Left, + window.listener_for( + &view, + |this, + event: &MouseDownEvent, + window, + cx| { + this.start_sftp_editor_resize( + event.position, + window.viewport_size(), + cx, + ); + window.prevent_default(); + cx.stop_propagation(); + }, + ), + ) + .on_drag( + SftpEditorDrag::Resize, + |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }, + ) + .on_drag_move(window.listener_for( + &view, + |this, + event: &DragMoveEvent, + window, + cx| { + if matches!( + event.drag(cx), + SftpEditorDrag::Resize + ) { + this.update_sftp_editor_interaction( + event.event.position, + window.viewport_size(), + cx, + ); + } + }, + )) + .on_mouse_up( + MouseButton::Left, + window.listener_for(&view, |this, _, _, cx| { + this.finish_sftp_editor_interaction(cx); + }), + ) + .on_mouse_up_out( + MouseButton::Left, + window.listener_for(&view, |this, _, _, cx| { + this.finish_sftp_editor_interaction(cx); + }), + ) + .child( + Icon::new(IconName::ResizeCorner) + .size_3() + .absolute() + .right(px(2.)) + .bottom(px(2.)) + .text_color( + cx.theme() + .muted_foreground + .opacity(0.6), + ), + ), + ), + ), + ) + } + }) + }); + self.load_sftp_editor_content(window, cx); + } + + pub(crate) fn set_sftp_editor_encoding( + &mut self, + encoding: TextEncoding, + window: &mut Window, + cx: &mut Context, + ) { + let Some(state) = self.sftp_editor_state.clone() else { + return; + }; + if state.encoding == encoding { + return; + } + + let current_content = self.sftp_editor_input.read(cx).value().to_string(); + let dirty = state.loaded && current_content != state.original_content; + if !state.loaded || dirty { + if let Some(editor_state) = self.sftp_editor_state.as_mut() { + editor_state.encoding = encoding; + editor_state.has_bom = encoding.default_bom(); + if !state.loaded { + editor_state.decode_had_errors = false; + } + editor_state.error = None; + editor_state.message = + Some(t!("file_encoding_changed", encoding = encoding.label()).to_string()); + } + cx.notify(); + return; + } + + let (content, had_errors, has_bom) = encoding.decode_file(&state.raw_content); + self.sftp_editor_input.update(cx, |input, cx| { + input.set_value(content.clone(), window, cx); + }); + if let Some(editor_state) = self.sftp_editor_state.as_mut() { + editor_state.encoding = encoding; + editor_state.original_content = content; + editor_state.has_bom = has_bom; + editor_state.decode_had_errors = had_errors; + editor_state.message = None; + editor_state.error = None; + } + cx.notify(); + } + + pub(crate) fn load_sftp_editor_content(&mut self, window: &mut Window, cx: &mut Context) { + let Some(state) = self.sftp_editor_state.clone() else { + return; + }; + let Some(handle) = self.sftp_handles.get(&state.group_id).cloned() else { + if let Some(state) = self.sftp_editor_state.as_mut() { + state.loading = false; + state.error = Some(t!("sftp_connection_unavailable").to_string()); + } + cx.notify(); + return; + }; + + if let Some(state) = self.sftp_editor_state.as_mut() { + state.loaded = false; + state.loading = true; + state.saving = false; + state.message = None; + state.error = None; + } + cx.notify(); + + let group_id = state.group_id; + let remote_path = state.remote_path; + let response = handle.read_text_file(remote_path.clone()); + cx.spawn_in(window, async move |this, cx| { + let result = response + .await + .unwrap_or_else(|_| Err(t!("sftp_connection_unavailable").to_string())); + let _ = gpui::AsyncWindowContext::update(cx, |window, cx| { + let _ = this.update(cx, |this, cx| { + let is_current = this.active_dialog == Some(crate::app::DialogKind::SftpEditor) + && this.sftp_editor_state.as_ref().is_some_and(|state| { + state.group_id == group_id && state.remote_path == remote_path + }); + if !is_current { + return; + } + match result { + Ok(raw_content) => { + let preferred_encoding = this + .sftp_editor_state + .as_ref() + .map(|state| state.encoding) + .unwrap_or(TextEncoding::Utf8); + let encoding = TextEncoding::detect_bom(&raw_content) + .or_else(|| { + std::str::from_utf8(&raw_content) + .is_ok() + .then_some(TextEncoding::Utf8) + }) + .unwrap_or(preferred_encoding); + let (content, had_errors, has_bom) = encoding.decode_file(&raw_content); + this.sftp_editor_input.update(cx, |input, cx| { + input.set_value(content.clone(), window, cx); + input.focus_handle(cx).focus(window, cx); + }); + if let Some(state) = this.sftp_editor_state.as_mut() { + state.raw_content = raw_content; + state.original_content = content; + state.encoding = encoding; + state.has_bom = has_bom; + state.decode_had_errors = had_errors; + state.loaded = true; + state.loading = false; + state.message = None; + state.error = None; + } + } + Err(error) => { + if let Some(state) = this.sftp_editor_state.as_mut() { + state.loaded = false; + state.loading = false; + state.error = + Some(t!("open_remote_file_failed", err = error).to_string()); + } + } + } + cx.notify(); + }); + }); + Ok::<(), anyhow::Error>(()) + }) + .detach(); + } + + pub(crate) fn save_sftp_editor_content(&mut self, window: &mut Window, cx: &mut Context) { + let Some(state) = self.sftp_editor_state.clone() else { + return; + }; + if !state.loaded || state.loading || state.saving { + return; + } + let content = self.sftp_editor_input.read(cx).value().to_string(); + if content == state.original_content { + return; + } + if state.decode_had_errors { + if let Some(state) = self.sftp_editor_state.as_mut() { + state.error = Some( + t!("encoding_decode_warning", encoding = state.encoding.label()).to_string(), + ); + } + cx.notify(); + return; + } + let (encoded_content, encode_had_errors) = + state.encoding.encode_file(&content, state.has_bom); + if encode_had_errors { + if let Some(state) = self.sftp_editor_state.as_mut() { + state.error = Some( + t!("encoding_encode_failed", encoding = state.encoding.label()).to_string(), + ); + } + cx.notify(); + return; + } + if encoded_content.len() > crate::sftp::MAX_INLINE_EDIT_BYTES { + if let Some(state) = self.sftp_editor_state.as_mut() { + state.error = Some(t!("editor_content_too_large", max = "2 MB").to_string()); + } + cx.notify(); + return; + } + let Some(handle) = self.sftp_handles.get(&state.group_id).cloned() else { + if let Some(state) = self.sftp_editor_state.as_mut() { + state.error = Some(t!("sftp_connection_unavailable").to_string()); + } + cx.notify(); + return; + }; + + if let Some(state) = self.sftp_editor_state.as_mut() { + state.saving = true; + state.message = None; + state.error = None; + } + cx.notify(); + + let group_id = state.group_id; + let remote_path = state.remote_path; + let saved_bytes = encoded_content.clone(); + let response = handle.write_text_file(remote_path.clone(), encoded_content); + cx.spawn_in(window, async move |this, cx| { + let result = response + .await + .unwrap_or_else(|_| Err(t!("sftp_connection_unavailable").to_string())); + let _ = this.update(cx, |this, cx| { + let Some(state) = this + .sftp_editor_state + .as_mut() + .filter(|state| state.group_id == group_id && state.remote_path == remote_path) + else { + return; + }; + state.saving = false; + match result { + Ok(()) => { + state.raw_content = saved_bytes; + state.original_content = content; + state.decode_had_errors = false; + state.message = Some(t!("remote_file_saved").to_string()); + state.error = None; + } + Err(error) => { + state.message = None; + state.error = Some(t!("save_remote_file_failed", err = error).to_string()); + } + } + cx.notify(); + }); + Ok::<(), anyhow::Error>(()) + }) + .detach(); + } + + pub(crate) fn show_remote_processes_dialog( + &mut self, + view_mode: crate::app::ServerMonitorView, + window: &mut Window, + cx: &mut Context, + ) { + if self.active_dialog.is_some() + || !self.system_tab_id.as_ref().is_some_and(|tab_id| { + self.tabs.iter().any(|tab| { + tab.id == *tab_id && tab.kind == crate::terminal::TabKind::Ssh && tab.connected + }) + }) + { + return; + } + + self.active_dialog = Some(crate::app::DialogKind::Processes); + self.server_monitor_view = view_mode; + self.remote_processes.clear(); + self.remote_process_status = None; + self.expanded_process_pid = None; + Self::set_input_value(&self.remote_process_filter_input, "", window, cx); + self.sort_remote_processes(); + self.request_active_process_snapshot(); + + let view = cx.entity(); + let title = t!("system_processes").to_string(); + + window.open_dialog(cx, move |dialog: Dialog, _window, _| { + dialog + .title(title.clone()) + .w(px(720.)) + .h(px(560.)) + .on_close({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if this.active_dialog == Some(crate::app::DialogKind::Processes) { + this.active_dialog = None; + } + cx.notify(); + }); + } + }) + .content({ + let view = view.clone(); + move |content, _window, cx| { + content.child(view.update(cx, |this, cx| { + this.render_remote_process_list(view_mode, cx) + })) + } + }) + }); + } + + pub(crate) fn show_remote_ports_dialog(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_dialog.is_some() + || !self.system_tab_id.as_ref().is_some_and(|tab_id| { + self.tabs.iter().any(|tab| { + tab.id == *tab_id && tab.kind == crate::terminal::TabKind::Ssh && tab.connected + }) + }) + { + return; + } + + self.active_dialog = Some(crate::app::DialogKind::Ports); + self.remote_ports.clear(); + self.remote_ports_status = None; + Self::set_input_value(&self.remote_port_filter_input, "", window, cx); + self.request_active_port_snapshot(); + + let view = cx.entity(); + window.open_dialog(cx, move |dialog: Dialog, _window, _| { + dialog + .title(t!("network_ports").to_string()) + .w(px(760.)) + .h(px(560.)) + .on_close({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if this.active_dialog == Some(crate::app::DialogKind::Ports) { + this.active_dialog = None; + } + cx.notify(); + }); + } + }) + .content({ + let view = view.clone(); + move |content, _window, cx| { + content.child(view.update(cx, |this, cx| this.render_remote_port_list(cx))) + } + }) + }); + } + pub(crate) fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context) { if self.active_dialog.is_some() { return; @@ -1330,7 +2976,7 @@ impl Ashell { let view = cx.entity(); - // Unbind all workspace keys so they don't interfere with keybinding recording + // Suspend workspace commands that could interfere with recording. Quit stays active. crate::app::keybinding_recorder::unbind_all_workspace_keys(cx, &self.config); self.keybinds_suspended = true; @@ -1396,6 +3042,7 @@ impl Ashell { if ev.keystroke.key == "escape" { this.recording_action = None; + crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); cx.notify(); return; } @@ -1410,6 +3057,7 @@ impl Ashell { this.recording_action = None; this.keybind_error = None; this.config.set_key_binding(&action, "none"); + crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); this.save_preferences_background(); cx.notify(); return; @@ -1433,6 +3081,7 @@ impl Ashell { action.clone(), t!("keybind_conflict", key = formatted, action = conflict_label).to_string(), )); + crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); cx.notify(); return; } @@ -1440,6 +3089,7 @@ impl Ashell { this.recording_action = None; this.keybind_error = None; this.config.set_key_binding(&action, &new_key); + crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); this.save_preferences_background(); cx.notify(); }); @@ -1451,6 +3101,7 @@ impl Ashell { view.update(cx, |this, cx| { if this.recording_action.is_some() { this.recording_action = None; + crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); cx.notify(); } }); @@ -1477,7 +3128,7 @@ impl Ashell { let state = view.read(cx); (state.follow_system_theme, state.theme_mode.is_dark()) }; - Button::new("theme-mode-dropdown") + pointer_button("theme-mode-dropdown") .small() .icon(if follow_system { IconName::Sun } else if is_dark_mode { IconName::Moon } else { IconName::Sun }) .label(if follow_system { t!("follow_system").to_string() } else if is_dark_mode { t!("use_dark_mode").to_string() } else { t!("use_light_mode").to_string() }) @@ -1488,7 +3139,7 @@ impl Ashell { let state = view.read(cx); (state.follow_system_theme, state.theme_mode.is_dark()) }; - menu = menu.min_w(160.) + menu = menu.min_w(0.) .item( PopupMenuItem::new(t!("follow_system").to_string()) .checked(follow_system) @@ -1525,7 +3176,7 @@ impl Ashell { let view = view_clone_for_general.clone(); move |_, _window, cx| { let current_theme = view.read(cx).light_theme_name.to_string(); - Button::new("light-theme-dropdown") + pointer_button("light-theme-dropdown") .small() .icon(IconName::Sun) .label(current_theme.clone()) @@ -1535,7 +3186,7 @@ impl Ashell { let current_theme = view.read(cx).light_theme_name.to_string(); let themes = gpui_component::ThemeRegistry::global(cx).sorted_themes(); let light_themes: Vec<_> = themes.into_iter().filter(|t| !t.mode.is_dark()).map(|t| t.name.clone()).collect(); - menu = menu.min_w(160.).max_h(px(320.)).scrollable(true); + menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); for theme_name in light_themes { let checked = theme_name == current_theme; menu = menu.item( @@ -1561,7 +3212,7 @@ impl Ashell { let view = view_clone_for_general.clone(); move |_, _window, cx| { let current_theme = view.read(cx).dark_theme_name.to_string(); - Button::new("dark-theme-dropdown") + pointer_button("dark-theme-dropdown") .small() .icon(IconName::Moon) .label(current_theme.clone()) @@ -1571,7 +3222,7 @@ impl Ashell { let current_theme = view.read(cx).dark_theme_name.to_string(); let themes = gpui_component::ThemeRegistry::global(cx).sorted_themes(); let dark_themes: Vec<_> = themes.into_iter().filter(|t| t.mode.is_dark()).map(|t| t.name.clone()).collect(); - menu = menu.min_w(160.).max_h(px(320.)).scrollable(true); + menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); for theme_name in dark_themes { let checked = theme_name == current_theme; menu = menu.item( @@ -1597,7 +3248,7 @@ impl Ashell { let view = view_clone_for_general.clone(); move |_, _window, cx| { let current_style = view.read(cx).config.title_bar_style(); - Button::new("title-bar-style-dropdown") + pointer_button("title-bar-style-dropdown") .small() .label(match current_style { crate::session::config::TitleBarStyle::Native => t!("title_bar_native").to_string(), @@ -1607,7 +3258,7 @@ impl Ashell { let view = view.clone(); move |mut menu, window, cx| { let current_style = view.read(cx).config.title_bar_style(); - menu = menu.min_w(160.) + menu = menu.min_w(0.) .item( PopupMenuItem::new(t!("title_bar_native").to_string()) .checked(current_style == crate::session::config::TitleBarStyle::Native) @@ -1647,9 +3298,9 @@ impl Ashell { h_flex() .items_center() .gap_3() - .child(Button::new("ui-font-size-down").small().label("-").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(-1.0, cx)))) + .child(pointer_button("ui-font-size-down").small().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").small().label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(1.0, cx)))) + .child(pointer_button("ui-font-size-up").small().label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(1.0, cx)))) .into_any_element() } }) @@ -1664,9 +3315,9 @@ impl Ashell { h_flex() .items_center() .gap_3() - .child(Button::new("terminal-font-size-down").small().label("-").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(-1.0, cx)))) + .child(pointer_button("terminal-font-size-down").small().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("terminal-font-size-up").small().label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(1.0, cx)))) + .child(pointer_button("terminal-font-size-up").small().label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(1.0, cx)))) .into_any_element() } }) @@ -1678,7 +3329,7 @@ impl Ashell { SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { - Button::new("ui-font-dropdown") + pointer_button("ui-font-dropdown") .small() .icon(IconName::ChevronsUpDown) .label({ @@ -1698,7 +3349,7 @@ impl Ashell { 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); + menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); menu = menu.item( PopupMenuItem::new(t!("system_default").to_string()) .checked(current == *".SystemUIFont" || current.is_empty()) @@ -1742,7 +3393,7 @@ impl Ashell { SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { - Button::new("terminal-font-dropdown") + pointer_button("terminal-font-dropdown") .small() .icon(IconName::ChevronsUpDown) .label({ @@ -1759,7 +3410,7 @@ impl Ashell { 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); + menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); let maple_font = "Maple Mono NF CN".to_string(); let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed); if !using_system_maple && names.contains(&maple_font) { @@ -1798,7 +3449,7 @@ impl Ashell { move |_, _window, cx| { use crate::session::config::CursorStyle; let current = view.read(cx).cursor_style; - Button::new("cursor-style-dropdown") + pointer_button("cursor-style-dropdown") .small() .icon(IconName::ChevronsUpDown) .label(match current { @@ -1812,7 +3463,7 @@ impl Ashell { move |mut menu, window, cx| { use crate::session::config::CursorStyle; let current = view.read(cx).cursor_style; - menu = menu.min_w(160.).max_h(px(320.)).scrollable(true); + menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); for style in [ CursorStyle::Default, CursorStyle::Blink, @@ -1852,7 +3503,7 @@ impl Ashell { SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { - Switch::new("right-click-copy-paste") + pointer_switch("right-click-copy-paste") .small() .checked(view.read(cx).config.right_click_copy_paste()) .on_click(window.listener_for(&view, |this, checked, _, cx| { @@ -1871,7 +3522,7 @@ impl Ashell { SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { - Switch::new("keyword-highlight") + pointer_switch("keyword-highlight") .small() .checked(view.read(cx).config.keyword_highlight()) .on_click(window.listener_for(&view, |this, checked, _, cx| { @@ -1884,13 +3535,33 @@ impl Ashell { }) ) ) + .item( + SettingItem::new( + t!("remember_tabs").to_string(), + SettingField::render({ + let view = view_clone_for_general.clone(); + move |_, window, cx| { + pointer_switch("remember-tabs") + .small() + .checked(view.read(cx).config.remember_tabs()) + .on_click(window.listener_for(&view, |this, checked, _, cx| { + this.config.set_remember_tabs(*checked); + this.capture_tabs_state(); + this.save_preferences_background(); + cx.notify(); + })) + .into_any_element() + } + }) + ) + ) .item( SettingItem::new( t!("lock_layout").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { - Switch::new("lock-layout") + pointer_switch("lock-layout") .small() .checked(view.read(cx).config.lock_layout()) .on_click(window.listener_for(&view, |this, checked, _, cx| { @@ -1909,7 +3580,7 @@ impl Ashell { SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { - Button::new("monitoring-position-dropdown") + pointer_button("monitoring-position-dropdown") .small() .icon(IconName::PanelLeftOpen) .label({ @@ -1926,7 +3597,7 @@ impl Ashell { let view = view.clone(); move |mut menu, window, cx| { let pos = view.read(cx).config.monitoring_position().to_string(); - menu = menu.min_w(160.) + menu = menu.min_w(0.) .item( PopupMenuItem::new(t!("position_bottom").to_string()) .checked(pos == "Bottom") @@ -1968,7 +3639,7 @@ impl Ashell { SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { - Button::new("language-dropdown") + pointer_button("language-dropdown") .small() .icon(IconName::Globe) .label({ @@ -1985,7 +3656,7 @@ impl Ashell { let view = view.clone(); move |mut menu, window, cx| { let current_locale = view.read(cx).config.locale().to_string(); - menu = menu.min_w(160.) + menu = menu.min_w(0.) .item( PopupMenuItem::new(t!("follow_system").to_string()) .checked(current_locale == "system") @@ -2022,7 +3693,7 @@ impl Ashell { SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, _cx| { - Button::new("reset-layout") + pointer_button("reset-layout") .small() .label(t!("reset").to_string()) .on_click(window.listener_for(&view, |this, _, window, cx| { @@ -2049,7 +3720,7 @@ impl Ashell { h_flex() .gap_2() .child( - Button::new("backup-export") + pointer_button("backup-export") .small() .label(t!("backup_export").to_string()) .on_click(window.listener_for(&view, |this, _, window, cx| { @@ -2057,7 +3728,7 @@ impl Ashell { })) ) .child( - Button::new("backup-import") + pointer_button("backup-import") .small() .label(t!("backup_import").to_string()) .on_click(window.listener_for(&view, |this, _, window, cx| { @@ -2100,14 +3771,14 @@ impl Ashell { h_flex() .gap_2() .child( - Button::new("sync-backend-webdav") + pointer_button("sync-backend-webdav") .small() .label("WebDAV") .when(!is_s3, |button| button.primary()) .on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("webdav", cx))) ) .child( - Button::new("sync-backend-s3") + pointer_button("sync-backend-s3") .small() .label("S3") .when(is_s3, |button| button.primary()) @@ -2132,8 +3803,8 @@ impl Ashell { .child( h_flex() .gap_2() - .child(Button::new("sync-download").small().disabled(in_progress).label(t!("sync_download").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.download_sync_config(cx)))) - .child(Button::new("sync-upload").small().disabled(in_progress).label(t!("sync_upload").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.upload_sync_config(cx)))), + .child(pointer_button("sync-download").small().disabled(in_progress).label(t!("sync_download").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.download_sync_config(cx)))) + .child(pointer_button("sync-upload").small().disabled(in_progress).label(t!("sync_upload").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.upload_sync_config(cx)))), ) .child(div().text_sm().text_color(cx.theme().muted_foreground).child(status)) } @@ -2152,7 +3823,7 @@ impl Ashell { SettingField::render({ let view = view.clone(); move |_, window, cx| { - Switch::new("use-proxy") + pointer_switch("use-proxy") .small() .checked(view.read(cx).config.use_proxy()) .on_click(window.listener_for(&view, |this, checked, _, cx| { @@ -2171,7 +3842,7 @@ impl Ashell { SettingField::render({ let view = view.clone(); move |_, window, cx| { - Switch::new("read-env-proxy") + pointer_switch("read-env-proxy") .small() .checked(view.read(cx).config.read_env_proxy()) .on_click(window.listener_for(&view, |this, checked, _, cx| { @@ -2200,7 +3871,7 @@ impl Ashell { h_flex() .gap_2() .child( - Button::new("global-proxy-type-socks5") + pointer_button("global-proxy-type-socks5") .small() .label("SOCKS5") .when(proxy_type == "socks5", |b| b.primary()) @@ -2210,7 +3881,7 @@ impl Ashell { })) ) .child( - Button::new("global-proxy-type-http") + pointer_button("global-proxy-type-http") .small() .label("HTTP") .when(proxy_type == "http", |b| b.primary()) @@ -2225,7 +3896,7 @@ impl Ashell { .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_user").to_string())).child(Input::new(&global_proxy_user_input).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_password").to_string())).child(Input::new(&global_proxy_password_input).w_full())) .child( - Button::new("save-global-proxy") + pointer_button("save-global-proxy") .small() .primary() .label(t!("save_proxy").to_string()) @@ -2290,7 +3961,7 @@ impl Ashell { .child(t!("about_feedback_hint")), ) .child( - Button::new("github-link") + pointer_button("github-link") .label("https://github.com/rust-kotlin/ashell") .ghost() .on_click(|_, _window, _cx| { diff --git a/src/app/ui.rs b/src/app/ui.rs index 2bf641d..afc18b9 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -1,18 +1,20 @@ +use std::collections::HashSet; + use crate::app::resizable::{h_resizable, resizable_panel, v_resizable}; use gpui::{ - Context, ElementId, Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement, - MouseButton, MouseDownEvent, ParentElement as _, PathBuilder, Pixels, Render, - StatefulInteractiveElement as _, Styled as _, Window, canvas, div, hsla, point, - prelude::FluentBuilder as _, px, relative, rems, uniform_list, + Anchor, AppContext as _, Context, ElementId, Focusable as _, FontWeight, Hsla, + InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, + PathBuilder, Pixels, Render, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, + hsla, point, prelude::FluentBuilder as _, px, relative, rems, uniform_list, }; use gpui_component::{ ActiveTheme, Disableable as _, ElementExt, Icon, IconName, InteractiveElementExt as _, Root, Sizable as _, Size, - button::{Button, ButtonVariants as _}, - checkbox::Checkbox, + button::ButtonVariants as _, h_flex, input::Input, - menu::{ContextMenuExt as _, PopupMenuItem}, + menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenuItem}, + popover::Popover, progress::Progress, scroll::{ScrollableElement as _, Scrollbar, ScrollbarShow}, tab::{Tab, TabBar}, @@ -22,13 +24,80 @@ use rust_i18n::t; use crate::{ Ashell, PaneLayout, - app::constants::{COLLAPSED_SIDEBAR_WIDTH, SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT}, + app::{ + ServerMonitorView, + constants::{COLLAPSED_SIDEBAR_WIDTH, SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT}, + controls::{PointerClipboard, pointer_button, pointer_checkbox}, + }, sftp::format_mtime, - sftp::ops::is_editable_text_file, - system::format_bytes, + system::{RemotePort, RemoteProcess, format_bytes}, terminal::{self, TabKind}, + text_encoding::TERMINAL_ENCODINGS, }; +fn process_matches_filter(process: &RemoteProcess, filter: &str) -> bool { + if filter.is_empty() { + return true; + } + + format!( + "{} {} {} {:.2} {}", + process.pid, + process.user, + process.command, + process.cpu_percent, + format_bytes(process.memory_bytes) + ) + .to_lowercase() + .contains(filter) +} + +fn port_matches_filter(port: &RemotePort, filter: &str) -> bool { + if filter.is_empty() { + return true; + } + + format!( + "{} {} {} {} {} {}", + port.protocol, + port.address, + port.port, + port.state, + port.pid.map(|pid| pid.to_string()).unwrap_or_default(), + port.process + ) + .to_lowercase() + .contains(filter) +} + +fn connection_matches_filter(session: &crate::session::config::Session, filter: &str) -> bool { + if filter.is_empty() { + return true; + } + + let detail = if session.protocol == "serial" { + format!("serial {} {}", session.host, session.baud_rate) + } else { + format!("ssh {} {} {}", session.user, session.host, session.port) + }; + format!("{} {}", session.name, detail) + .to_lowercase() + .contains(filter) +} + +fn compact_menu_width(labels: &[&str]) -> Pixels { + let display_units = labels + .iter() + .map(|label| { + label + .chars() + .map(|character| if character.is_ascii() { 1.0 } else { 2.0 }) + .sum::() + }) + .fold(0.0, f32::max); + px((display_units * 7.2 + 28.0).clamp(72.0, 240.0)) +} + impl Ashell { fn render_home_page(&self, cx: &mut Context) -> impl IntoElement { v_flex() @@ -53,13 +122,13 @@ impl Ashell { h_flex() .gap_3() .child( - Button::new("home-open-local") + pointer_button("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") + pointer_button("home-open-session") .ghost() .label(t!("open_session").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -73,6 +142,9 @@ impl Ashell { let state = self.body_panels.clone(); let minimized = self.sftp_panel_minimized; + // Changing the SFTP panel is an explicit layout change after a reset. + self.is_layout_reset = false; + if !minimized { let sizes = state.read(cx).sizes(); if sizes.len() > 1 { @@ -115,6 +187,89 @@ impl Ashell { cx.notify(); } + fn render_transfers_button( + &self, + id: &'static str, + cx: &mut Context, + ) -> impl IntoElement { + pointer_button(id) + .ghost() + .small() + .icon(IconName::ChevronsUpDown) + .label(t!("transfers").to_string()) + .tooltip(t!("transfers").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.show_transfers_dialog(window, cx); + })) + } + + fn render_command_history_popover( + &self, + popover_id: &'static str, + trigger_id: &'static str, + anchor: Anchor, + cx: &mut Context, + ) -> impl IntoElement { + Popover::new(popover_id) + .anchor(anchor) + .open(self.show_command_history) + .on_open_change(cx.listener(|this, open, _, cx| { + let open = *open && matches!(this.active_kind(), Some(TabKind::Ssh)); + if this.show_command_history != open { + this.show_command_history = open; + if !open { + this.selected_command_history.clear(); + } + cx.notify(); + } + })) + .trigger( + pointer_button(trigger_id) + .ghost() + .small() + .icon(IconName::Menu) + .label(t!("command_history_short").to_string()) + .tooltip(t!("command_history").to_string()), + ) + .w(px(480.)) + .p_0() + .child(self.render_command_history_popover_content(cx)) + } + + fn render_sftp_minimized_bar(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .flex_none() + .w_full() + .min_w(px(0.)) + .overflow_hidden() + .h(px(24.)) + .px_3() + .items_center() + .gap_2() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().tab_bar) + .child(div().flex_1()) + .child(self.render_transfers_button("sftp-transfers-minimized", cx)) + .child(self.render_command_history_popover( + "sftp-command-history-minimized-popover", + "sftp-command-history-minimized", + Anchor::BottomRight, + cx, + )) + .child( + pointer_button("sftp-minimize-toggle") + .ghost() + .small() + .icon(IconName::ChevronUp) + .label(t!("panel_expand_short").to_string()) + .tooltip(t!("panel_expand").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_sftp_minimized(window, cx); + })), + ) + } + fn render_sftp_panel( &mut self, _window: &mut Window, @@ -122,63 +277,13 @@ impl Ashell { ) -> impl IntoElement { let active_sftp = self.active_sftp(); - // Compute active download progress for status bar and minimized header - let build_summary = |kind: crate::terminal::TransferType| -> Option<(String, String, f32)> { - let active: Vec<&crate::terminal::Transfer> = self - .transfers - .iter() - .filter(|t| { - matches!( - t.state, - crate::terminal::TransferState::Running - | crate::terminal::TransferState::Paused - ) && t.info.kind == kind - }) - .collect(); - if active.is_empty() { - return None; - } - Some(if active.len() == 1 { - let t = &active[0]; - let pct = t.total.and_then(|total| { - if total > 0 { - Some((t.transferred as f64 / total as f64 * 100.0) as f32) - } else { - None - } - }); - match pct { - Some(pct) => (t.info.name.clone(), format!("{:.0}%", pct), pct), - None => (t.info.name.clone(), "-".to_string(), 0.0), - } - } else { - let total_transferred: u64 = active.iter().map(|t| t.transferred).sum(); - let total_total: u64 = active.iter().filter_map(|t| t.total).sum(); - let pct = if total_total > 0 { - Some((total_transferred as f64 / total_total as f64 * 100.0) as f32) - } else { - None - }; - let label = match kind { - crate::terminal::TransferType::Download => { - t!("files_downloading", count = active.len()).to_string() - } - crate::terminal::TransferType::Upload => { - t!("files_uploading", count = active.len()).to_string() - } - }; - match pct { - Some(pct) => (label, format!("{:.0}%", pct), pct), - None => (label, "-".to_string(), 0.0), - } - }) - }; - let dl_summary = build_summary(crate::terminal::TransferType::Download); - let ul_summary = build_summary(crate::terminal::TransferType::Upload); - let has_transfers = dl_summary.is_some() || ul_summary.is_some(); - + let show_hidden_files = self.show_hidden_files; + let header_view = cx.entity(); let header = h_flex() .flex_none() + .w_full() + .min_w(px(0.)) + .overflow_hidden() .h(px(34.)) .px_2() .items_center() @@ -186,127 +291,103 @@ impl Ashell { .border_b_1() .border_color(cx.theme().border) .bg(cx.theme().tab_bar) + .child( + Icon::new(IconName::FolderOpen) + .with_size(Size::Small) + .text_color(cx.theme().primary), + ) .child( div() + .flex_1() + .min_w(px(0.)) + .truncate() .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(); + .when(!self.sftp_panel_minimized, |this| { + this.child(self.render_transfers_button("sftp-transfers-header", cx)) + .child(self.render_command_history_popover( + "sftp-command-history-popover", + "sftp-command-history", + Anchor::TopRight, + cx, + )) + }) + .when(active_sftp.is_some(), |this| { this.child( - Button::new("sftp-sync-cwd") + pointer_button("sftp-header-more") .ghost() .small() - .icon(IconName::SquareTerminal) - .label(t!("sync_cwd").to_string()) - .tooltip(t!("sync_cwd_tooltip").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.sync_cwd_from_terminal(window, cx); - })), + .icon(IconName::Ellipsis) + .label(t!("sftp_more_actions_short").to_string()) + .tooltip(t!("sftp_more_actions").to_string()) + .dropdown_menu_with_anchor(Anchor::BottomRight, { + let view = header_view.clone(); + move |menu, window, _| { + menu.min_w(0.) + .item( + PopupMenuItem::new( + t!("reveal_current_directory").to_string(), + ) + .on_click( + window.listener_for(&view, |this, _, _, cx| { + this.reveal_current_sftp_directory(cx); + }), + ), + ) + .item( + PopupMenuItem::new( + t!("collapse_all_directories").to_string(), + ) + .on_click( + window.listener_for(&view, |this, _, _, cx| { + this.collapse_sftp_tree(cx); + }), + ), + ) + .separator() + .item( + PopupMenuItem::new(t!("hidden").to_string()) + .checked(show_hidden_files) + .on_click(window.listener_for( + &view, + move |this, _, _, cx| { + this.show_hidden_files = !show_hidden_files; + this.config + .set_show_hidden_files(!show_hidden_files); + this.save_preferences_background(); + cx.notify(); + }, + )), + ) + } + }), ) - .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-new-folder") - .ghost() - .small() - .icon(IconName::Folder) - .label(t!("new_folder").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.sftp_creating_folder = true; - this.sftp_new_folder_input.update(cx, |input, cx| { - input.set_value("", window, cx); - input.focus_handle(cx).focus(window, cx); - }); - cx.notify(); - })), - ) - .child( - Button::new("sftp-delete-selected") - .ghost() - .small() - .icon(IconName::Close) - .label(if selected_entries.is_empty() { - t!("delete_selected").to_string() - } else { - format!( - "{} ({})", - t!("delete_selected").to_string(), - selected_entries.len() - ) - }) - .disabled(selected_entries.is_empty()) - .on_click(cx.listener(|this, _, window, cx| { - this.show_delete_confirm_dialog(window, 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; - this.config.set_show_hidden_files(*checked); - this.save_preferences_background(); - cx.notify(); - })), - ) - }); + }) + .child( + pointer_button("sftp-minimize-toggle-header") + .ghost() + .small() + .icon(IconName::ChevronDown) + .label(t!("panel_minimize_short").to_string()) + .tooltip(t!("panel_minimize").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_sftp_minimized(window, cx); + })), + ); let Some(sftp) = active_sftp else { - let mut outer = v_flex() + let outer = v_flex() .gap_0() + .min_w(px(0.)) .border_color(cx.theme().border) .bg(cx.theme().background) .flex_1() .child( v_flex() .flex_1() + .min_w(px(0.)) .min_h(px(0.)) .when(self.sftp_panel_minimized, |this| this.hidden()) .child(header) @@ -323,135 +404,24 @@ impl Ashell { .child(t!("open_ssh_tab_sftp")), ), ), - ); - outer = outer.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().flex_1()) - .child( - Button::new("open-transfers") - .ghost() - .small() - .when(has_transfers, |this| { - let mut content = h_flex().items_center().gap_2(); - if let Some((ref label, ref pct_display, pct)) = dl_summary { - content = content.child( - h_flex() - .items_center() - .gap_1() - .child( - Icon::new(IconName::ArrowDown) - .with_size(Size::Small) - .text_color(cx.theme().primary), - ) - .child( - div() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(label.clone()), - ) - .child( - Progress::new("sftp-status-dl") - .with_size(px(4.)) - .value(pct) - .color(cx.theme().primary) - .w(px(50.0)), - ) - .child( - div() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(pct_display.clone()), - ), - ); - } - if let Some((ref label, ref pct_display, pct)) = ul_summary { - if dl_summary.is_some() { - content = content.child(div().w(px(6.))); - } - content = content.child( - h_flex() - .items_center() - .gap_1() - .child( - Icon::new(IconName::ArrowUp) - .with_size(Size::Small) - .text_color(cx.theme().primary), - ) - .child( - div() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(label.clone()), - ) - .child( - Progress::new("sftp-status-ul") - .with_size(px(4.)) - .value(pct) - .color(cx.theme().primary) - .w(px(50.0)), - ) - .child( - div() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(pct_display.clone()), - ), - ); - } - this.child(content) - .on_click(cx.listener(|this, _, window, cx| { - this.show_transfers_dialog(window, cx); - })) - }) - .when(!has_transfers, |this| { - this.icon(IconName::ArrowDown) - .label(t!("transfers").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.show_transfers_dialog(window, cx); - })) - }), - ) - .child( - Button::new("sftp-minimize-toggle") - .ghost() - .small() - .icon(if self.sftp_panel_minimized { - IconName::ChevronUp - } else { - IconName::ChevronDown - }) - .label(if self.sftp_panel_minimized { - t!("panel_expand").to_string() - } else { - t!("panel_minimize").to_string() - }) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_sftp_minimized(window, cx); - })), - ), - ); + ) + .when(self.sftp_panel_minimized, |this| { + this.child(self.render_sftp_minimized_bar(cx)) + }); return outer.into_any_element(); }; let selected_path = sftp.selected_path.clone(); let entries = sftp - .entries - .clone() - .into_iter() + .current_entries() + .iter() .filter(|entry| self.show_hidden_files || !entry.name.starts_with('.')) + .cloned() .collect::>(); - let status = sftp.status.clone(); + let tree_rows = sftp.tree_rows(self.show_hidden_files); + let current_path = sftp.current_path.clone(); + let current_loading = sftp.loading_directories.contains(¤t_path); + let current_error = sftp.directory_errors.get(¤t_path).cloned(); let selected_entries = sftp.selected_entries.clone(); let all_selected = !entries.is_empty() && entries @@ -459,12 +429,150 @@ impl Ashell { .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.); + let icon_col_width = px(16.); + let right_panel_width = self + .sftp_tree_panels + .read(cx) + .sizes() + .get(1) + .map(|size| size.as_f32()) + .or_else(|| { + self.config + .sftp_tree_panels() + .and_then(|sizes| sizes.get(1).copied()) + }) + .unwrap_or(800.); + let estimated_file_columns_viewport_width = px((right_panel_width - 76.).max(1.)); + let file_columns_viewport_width = self + .remote_files_columns_viewport_width + .unwrap_or(estimated_file_columns_viewport_width); + let default_size_col_width = 64.; + let default_modified_col_width = 128.; + let default_name_col_width = (file_columns_viewport_width.as_f32() + - default_size_col_width + - default_modified_col_width) + .max(96.); + let file_column_sizes = self.sftp_file_columns.read(cx).sizes().clone(); + let configured_file_column_sizes = if self.config.sftp_file_columns_customized() { + self.config.sftp_file_columns().cloned().unwrap_or_default() + } else { + Vec::new() + }; + let column_width = |index: usize, default: f32, min: f32| { + let width = file_column_sizes + .get(index) + .map(|size| size.as_f32()) + .or_else(|| configured_file_column_sizes.get(index).copied()) + .unwrap_or(default) + .max(min); + px(width) + }; + let name_col_width = column_width(0, default_name_col_width, 96.); + let size_col_width = column_width(1, default_size_col_width, 56.); + let modified_col_width = column_width(2, default_modified_col_width, 112.); + let show_size_column = true; + let show_modified_column = true; + let configured_file_columns_width = name_col_width + size_col_width + modified_col_width; + let file_columns_overflow = + configured_file_columns_width > file_columns_viewport_width + px(1.); + let show_file_columns_scrollbar = file_columns_overflow && !entries.is_empty(); + let file_columns_content_width = configured_file_columns_width; + let file_columns_scroll_handle = self.remote_files_horizontal_scroll_handle.clone(); + let tree_panel_width = self + .config + .sftp_tree_panels() + .and_then(|sizes| sizes.first().copied()) + .unwrap_or(220.) + .clamp(160., 720.); + let list_state_message = if current_loading { + t!("loading_directory").to_string() + } else if let Some(reason) = current_error { + t!("directory_load_failed", reason = reason).to_string() + } else { + t!("empty_directory").to_string() + }; + let column_resize_view = view.clone(); + let column_viewport_view = view.clone(); + let file_columns_header = h_resizable("sftp-file-columns") + .independent_resize() + .lock(self.config.lock_layout()) + .with_state(&self.sftp_file_columns) + .on_resize(move |state, _, cx| { + let column_sizes = state + .read(cx) + .sizes() + .iter() + .take(3) + .map(|size| size.as_f32()) + .collect::>(); + column_resize_view.update(cx, move |this, _| { + this.is_layout_reset = false; + this.config.set_sftp_file_columns(Some(column_sizes)); + this.config.set_sftp_file_columns_customized(true); + this.save_preferences_background(); + }); + }) + .child( + resizable_panel() + .size(name_col_width) + .size_range(px(96.)..Pixels::MAX) + .child( + h_flex() + .size_full() + .min_w(px(0.)) + .items_center() + .gap_2() + .pr_2() + .child(div().w(icon_col_width).flex_none()) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(t!("name")), + ), + ), + ) + .when(show_size_column, |this| { + this.child( + resizable_panel() + .size(size_col_width) + .size_range(px(56.)..Pixels::MAX) + .flex_none() + .child( + h_flex() + .size_full() + .items_center() + .px_2() + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(t!("size")), + ), + ) + }) + .when(show_modified_column, |this| { + this.child( + resizable_panel() + .size(modified_col_width) + .size_range(px(112.)..Pixels::MAX) + .flex_none() + .child( + h_flex() + .size_full() + .items_center() + .px_2() + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(t!("modified")), + ), + ) + }); let mut outer = v_flex() .gap_0() + .min_w(px(0.)) .border_color(cx.theme().border) .bg(cx.theme().background) .flex_1() @@ -482,11 +590,15 @@ impl Ashell { outer = outer.child( v_flex() .flex_1() + .min_w(px(0.)) .min_h(px(0.)) .when(self.sftp_panel_minimized, |this| this.hidden()) .child(header) .child( h_flex() + .w_full() + .min_w(px(0.)) + .overflow_hidden() .h(px(36.)) .items_center() .gap_2() @@ -495,384 +607,974 @@ impl Ashell { .border_color(cx.theme().border) .bg(cx.theme().muted) .child( - Button::new("sftp-up") + pointer_button("sftp-up") .ghost() .small() .icon(IconName::ChevronUp) + .tooltip(t!("parent_directory").to_string()) .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() + Input::new(&self.sftp_path_input) .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")), - ), + .tab_index(0), ) .child( - div() - .w(size_col_width) - .flex_none() - .text_size(rems(0.917)) - .text_color(cx.theme().muted_foreground) - .child(t!("size")), + pointer_button("sftp-sync-cwd") + .ghost() + .small() + .icon(IconName::Replace) + .label(t!("sync_cwd").to_string()) + .tooltip(t!("sync_cwd_tooltip").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.sync_cwd_from_terminal(window, cx); + })), ) .child( - div() - .w(modified_col_width) - .flex_none() - .text_size(rems(0.917)) - .text_color(cx.theme().muted_foreground) - .child(t!("modified")), + pointer_button("sftp-refresh") + .ghost() + .small() + .icon(IconName::Redo) + .label(t!("refresh").to_string()) + .tooltip(t!("refresh").to_string()) + .on_click(cx.listener(|this, _, _, cx| this.refresh_sftp(cx))), + ) + .child( + pointer_button("sftp-create-upload") + .ghost() + .small() + .icon(IconName::Plus) + .label(t!("add").to_string()) + .tooltip(t!("create_or_upload").to_string()) + .dropdown_menu_with_anchor(Anchor::BottomRight, { + let view = view.clone(); + move |menu, window, _| { + menu.min_w(0.) + .item( + PopupMenuItem::new(t!("new_folder").to_string()) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + this.sftp_creating_folder = true; + this.sftp_new_folder_input.update( + cx, + |input, cx| { + input.set_value( + "", window, cx, + ); + input + .focus_handle(cx) + .focus(window, cx); + }, + ); + cx.notify(); + }, + )), + ) + .item( + PopupMenuItem::new(t!("upload_file").to_string()) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + this.upload_sftp_files(window, cx); + }, + )), + ) + .item( + PopupMenuItem::new( + t!("upload_folder").to_string(), + ) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + this.upload_sftp_folder(window, cx); + }, + )), + ) + } + }), ), ) .child( div() .flex_1() - .relative() + .min_w(px(0.)) .min_h(px(0.)) - .child({ - let entries = entries.clone(); - let selected_entries = selected_entries.clone(); - let selected_path = selected_path.clone(); + .overflow_hidden() + .child( + h_resizable("sftp-browser") + .lock(self.config.lock_layout()) + .with_state(&self.sftp_tree_panels) + .on_resize({ let view = view.clone(); - let theme = cx.theme().clone(); - let icon_col_width = icon_col_width; - let size_col_width = size_col_width; - let modified_col_width = modified_col_width; - uniform_list( - "sftp-entries-list", - entries.len(), - move |range, window, _cx| { - range - .into_iter() - .filter_map(|ix| { - let entry = entries.get(ix)?; - let entry = entry.clone(); - let is_checked = - selected_entries.contains(&entry.full_path); - let is_selected = selected_path.as_deref() - == Some(entry.full_path.as_str()); - let name_color = if entry.is_dir { - theme.primary - } else { - theme.foreground - }; - let bg = if is_selected { - theme.secondary - } else if ix % 2 == 0 { - theme.background - } else { - theme.muted.opacity(0.5) - }; - Some( + move |_, _, cx| { + view.update(cx, |this, _| { + this.is_layout_reset = false; + }); + } + }) + .child( + resizable_panel() + .size(px(tree_panel_width)) + .size_range(px(160.)..px(720.)) + .flex_none() + .child( + v_flex() + .size_full() + .border_r_1() + .border_color(cx.theme().border) + .child( h_flex() - .w_full() - .h(px(28.)) + .flex_none() + .h(px(26.)) + .px_2() .items_center() - .px_3() - .gap_2() - .bg(bg) - .hover(|style| style.bg(theme.muted.opacity(0.8))) .border_b_1() - .border_color(theme.border.opacity(0.35)) - .on_mouse_down( - MouseButton::Left, - window.listener_for(&view, { - let entry = entry.clone(); - move |this, _, _, cx| { - this.dismiss_sftp_context_menu(cx); - this.select_sftp_entry( - entry.clone(), - cx, - ); - } - }), - ) - .on_mouse_down( - MouseButton::Right, - window.listener_for(&view, { - let entry = entry.clone(); - let remote_path = entry.full_path.clone(); - move |this, event: &MouseDownEvent, _, cx| { - this.mark_sftp_entry_selected( - &entry.full_path, - cx, - ); - this.open_sftp_context_menu( - remote_path.clone(), - entry.is_dir, - event.position, - cx, - ); - } - }), - ) + .border_color(cx.theme().border) + .bg(cx.theme().muted.opacity(0.8)) + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(t!("directories")), + ) + .child( + div() + .flex_1() + .relative() + .min_h(px(0.)) + .child({ + let rows = tree_rows.clone(); + let selected_directory = current_path.clone(); + let view = view.clone(); + let theme = cx.theme().clone(); + uniform_list( + "sftp-tree-list", + rows.len(), + move |range, window, _| { + range + .into_iter() + .filter_map(|ix| { + let row = rows.get(ix)?.clone(); + let selected = + row.path == selected_directory; + let toggle_tooltip = if let Some( + reason, + ) = row.error.as_ref() + { + format!( + "{}: {}", + t!("retry_directory"), + reason + ) + } else if row.expanded { + t!("collapse_directory") + .to_string() + } else { + t!("expand_directory") + .to_string() + }; + let folder_icon = if row.expanded { + IconName::FolderOpen + } else { + IconName::FolderClosed + }; + let row_path = row.path.clone(); + Some( + h_flex() + .w_full() + .h(px(28.)) + .items_center() + .pl(px( + 2. + row.depth as f32 + * 14., + )) + .pr_1() + .bg(if selected { + theme.secondary + } else { + theme.background + }) + .hover(|style| { + style.bg( + theme + .muted + .opacity(0.8), + ) + }) + .child( + h_flex() + .size_5() + .flex_none() + .items_center() + .justify_center() + .child( + pointer_button(( + "sftp-tree-toggle", + ix, + )) + .ghost() + .xsmall() + .icon(if row.expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .loading(row.loading) + .tooltip(toggle_tooltip) + .on_click( + window.listener_for( + &view, + { + let path = + row.path + .clone(); + move |this, + _, + _, + cx| { + this.toggle_sftp_tree_directory( + path.clone(), + cx, + ); + } + }, + ) + ), + ), + ) + .child( + h_flex() + .id(( + "sftp-tree-open", + ix, + )) + .flex_1() + .min_w(px(0.)) + .items_center() + .gap_1() + .cursor_pointer() + .tooltip({ + let path = + row_path.clone(); + move |window, cx| { + gpui_component::tooltip::Tooltip::new( + path.clone(), + ) + .build(window, cx) + } + }) + .on_mouse_down( + MouseButton::Left, + window.listener_for( + &view, + move |this, + _, + _, + cx| { + this.navigate_sftp( + row_path.clone(), + cx, + ); + }, + ), + ) + .child( + Icon::new(folder_icon) + .with_size( + Size::Small, + ) + .text_color( + theme.primary, + ), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .text_size( + rems(0.917), + ) + .child(row.label), + ) + ) + .into_any_element(), + ) + }) + .collect::>() + }, + ) + .size_full() + .track_scroll( + &self.remote_tree_scroll_handle, + ) + }) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(12.)) + .child( + Scrollbar::vertical( + &self.remote_tree_scroll_handle, + ) + .scrollbar_show( + ScrollbarShow::Scrolling, + ), + ), + ), + ), + ), + ) + .child( + resizable_panel() + .size_range(px(360.)..px(2400.)) + .child( + v_flex() + .size_full() + .when(self.sftp_creating_folder, |this| { + this.child( + h_flex() + .flex_none() + .h(px(32.)) + .px_2() + .items_center() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + Icon::new(IconName::Folder) + .with_size(Size::Small) + .text_color(cx.theme().primary), + ) + .child( + Input::new(&self.sftp_new_folder_input) + .flex_1() + .tab_index(0), + ) + .child( + pointer_button("cancel-sftp-new-folder") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(t!("cancel").to_string()) + .on_click(cx.listener( + |this, _, _, cx| { + this.sftp_creating_folder = + false; + cx.notify(); + }, + )), + ), + ) + }) + .child(if selected_entries.is_empty() { + 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() - .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, - ); - } - })), + pointer_checkbox("sftp-select-all") + .checked(all_selected) + .on_click(cx.listener( + |this, checked, _, cx| { + this.toggle_all_sftp_entries( + *checked, cx, + ); + }, + )), ), ) .child( - h_flex() + div() + .id("sftp-file-columns-header-scroll") + .flex() .flex_1() .min_w(px(0.)) - .items_center() - .gap_2() + .h_full() + .on_prepaint(move |bounds, _, cx| { + let width = bounds.size.width; + column_viewport_view.update( + cx, + |this, cx| { + let width_changed = this + .remote_files_columns_viewport_width + .map(|current| { + (current.as_f32() + - width.as_f32()) + .abs() + > 1. + }) + .unwrap_or(true); + if !width_changed { + return; + } + + this.remote_files_columns_viewport_width = + Some(width); + if !this + .config + .sftp_file_columns_customized() + { + this.sftp_file_columns = cx.new(|_| { + crate::app::resizable::ResizableState::default() + }); + } + cx.notify(); + }, + ); + }) + .track_scroll(&file_columns_scroll_handle) + .overflow_x_scroll() + .map(|mut scrollable| { + scrollable + .style() + .restrict_scroll_to_axis = + Some(true); + scrollable + }) .child( div() - .w(icon_col_width) + .w(file_columns_content_width) + .min_w(file_columns_content_width) + .max_w(file_columns_content_width) + .h_full() .flex_none() - .text_size(rems(1.0)) - .text_color(name_color) - .child(if entry.is_dir { - "📁" - } else { - "📄" - }), - ) + .child(file_columns_header), + ), + ) + .child(div().w(px(12.)).flex_none()) + .into_any_element() + } else { + 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( - div() - .flex_1() - .min_w(px(0.)) - .overflow_hidden() - .text_size(rems(1.0)) - .text_color(name_color) - .child(entry.name), + pointer_checkbox("sftp-select-all") + .checked(all_selected) + .on_click(cx.listener( + |this, checked, _, cx| { + this.toggle_all_sftp_entries( + *checked, cx, + ); + }, + )), ), ) .child( div() - .w(size_col_width) - .flex_none() + .flex_1() + .min_w(px(0.)) + .truncate() .text_size(rems(0.917)) - .text_color(theme.muted_foreground) - .child(if entry.is_dir { - "-".to_string() - } else { - format_bytes(entry.size) - }), + .text_color(cx.theme().muted_foreground) + .child( + t!( + "selected_items", + count = selected_entries.len() + ) + .to_string(), + ), ) .child( - div() - .w(modified_col_width) - .flex_none() - .text_size(rems(0.917)) - .text_color(theme.muted_foreground) - .child(format_mtime(entry.modified)), + pointer_button("sftp-download-selected") + .ghost() + .xsmall() + .icon(IconName::ArrowDown) + .tooltip( + t!( + "download_count", + count = selected_entries.len() + ) + .to_string(), + ) + .on_click(cx.listener( + |this, _, window, cx| { + this.download_selected_sftp_entries( + window, cx, + ); + }, + )), ) - .child(div().w(px(12.)).flex_none()) - .into_any_element(), - ) + .child( + pointer_button("sftp-delete-selected") + .danger() + .xsmall() + .icon(IconName::Delete) + .tooltip(t!("delete_selected").to_string()) + .on_click(cx.listener( + |this, _, window, cx| { + this.show_delete_confirm_dialog( + window, cx, + ); + }, + )), + ) + .into_any_element() }) - .collect::>() - }, - ) - .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( + div() + .flex_1() + .relative() + .min_h(px(0.)) + .child({ + let entries = entries.clone(); + let selected_entries = + selected_entries.clone(); + let selected_path = selected_path.clone(); + let view = view.clone(); + let theme = cx.theme().clone(); + let horizontal_scroll_handle = + file_columns_scroll_handle.clone(); + uniform_list( + "sftp-entries-list", + entries.len(), + move |range, window, _| { + range + .into_iter() + .filter_map(|ix| { + let entry = + entries.get(ix)?.clone(); + let modified_text = + format_mtime(entry.modified); + let is_checked = + selected_entries.contains( + &entry.full_path, + ); + let is_selected = selected_path + .as_deref() + == Some( + entry + .full_path + .as_str(), + ); + let name_color = if entry.is_dir { + theme.primary + } else { + theme.foreground + }; + let bg = if is_selected { + theme.secondary + } else if ix % 2 == 0 { + theme.background + } else { + theme.muted.opacity(0.5) + }; + Some( + h_flex() + .w_full() + .h(px(28.)) + .items_center() + .px_3() + .gap_2() + .bg(bg) + .hover(|style| { + style.bg( + theme + .muted + .opacity(0.8), + ) + }) + .border_b_1() + .border_color( + theme + .border + .opacity(0.35), + ) + .on_mouse_down( + MouseButton::Left, + window.listener_for( + &view, + { + let entry = + entry + .clone(); + move |this, + _, + _, + cx| { + this.dismiss_sftp_context_menu(cx); + this.select_sftp_entry( + entry + .clone(), + cx, + ); + } + }, + ), + ) + .on_mouse_down( + MouseButton::Right, + window.listener_for( + &view, + { + let entry = + entry + .clone(); + let remote_path = + entry + .full_path + .clone(); + move |this, + event: &MouseDownEvent, + _, + cx| { + this.mark_sftp_entry_selected( + &entry + .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( + pointer_checkbox( + 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( + div() + .id(( + "sftp-file-columns-row-scroll", + ix, + )) + .flex() + .flex_1() + .min_w(px(0.)) + .h_full() + .track_scroll( + &horizontal_scroll_handle, + ) + .overflow_x_scroll() + .map(|mut scrollable| { + // Preserve vertical wheel input for the outer file list. + scrollable + .style() + .restrict_scroll_to_axis = Some(true); + scrollable + }) + .child( + h_flex() + .w( + file_columns_content_width, + ) + .min_w( + file_columns_content_width, + ) + .max_w( + file_columns_content_width, + ) + .h_full() + .flex_none() + .items_center() + .child( + h_flex() + .w(name_col_width) + .flex_none() + .min_w(px(0.)) + .items_center() + .gap_2() + .pr_2() + .child( + div() + .w( + icon_col_width, + ) + .flex_none() + .child( + Icon::new( + if entry + .is_dir + { + IconName::FolderClosed + } else { + IconName::File + }, + ) + .with_size( + Size::Small, + ) + .text_color( + if entry + .is_dir + { + theme.primary + } else { + theme + .muted_foreground + }, + ), + ), + ) + .child( + div() + .id(("sftp-file-entry-name", ix)) + .flex_1() + .min_w(px(0.)) + .truncate() + .tooltip({ + let path = entry + .full_path + .clone(); + move |window, + cx| { + gpui_component::tooltip::Tooltip::new( + path.clone(), + ) + .build(window, cx) + } + }) + .text_size( + rems(1.0), + ) + .text_color( + name_color, + ) + .child( + entry + .name, + ), + ), + ) + .when( + show_size_column, + |this| { + this.child( + h_flex() + .w( + size_col_width, + ) + .flex_none() + .h_full() + .items_center() + .px_2() + .text_size( + rems(0.917), + ) + .text_color( + theme + .muted_foreground, + ) + .child( + if entry + .is_dir + { + "-" + .to_string() + } else { + format_bytes( + entry + .size, + ) + }, + ), + ) + }, + ) + .when( + show_modified_column, + |this| { + this.child( + h_flex() + .w( + modified_col_width, + ) + .flex_none() + .h_full() + .items_center() + .px_2() + .text_size( + rems(0.917), + ) + .text_color( + theme + .muted_foreground, + ) + .child( + modified_text, + ), + ) + }, + ) + ) + ) + .child( + div() + .w(px(12.)) + .flex_none(), + ) + .into_any_element(), + ) + }) + .collect::>() + }, + ) + .size_full() + .map(|mut list| { + // Keep horizontal gestures available to the file columns. + list.style().restrict_scroll_to_axis = + Some(true); + list + }) + .when(show_file_columns_scrollbar, |this| { + this.pb(px(12.)) + }) + .track_scroll( + &self.remote_files_scroll_handle, + ) + }) + .when(entries.is_empty(), |this| { + this.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .px_4() + .text_size(rems(0.917)) + .text_color( + cx.theme().muted_foreground, + ) + .child(list_state_message), + ) + }) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom(if show_file_columns_scrollbar { + px(12.) + } else { + px(0.) + }) + .w(px(16.)) + .child( + Scrollbar::vertical( + &self.remote_files_scroll_handle, + ) + .scrollbar_show( + ScrollbarShow::Scrolling, + ), + ), + ) + .when(show_file_columns_scrollbar, |this| { + this.child( + div() + .absolute() + .left(px(44.)) + .right(px(32.)) + .bottom_0() + .h(px(12.)) + .bg(cx.theme().background) + .child( + Scrollbar::horizontal( + &self.remote_files_horizontal_scroll_handle, + ) + .scroll_size(gpui::size( + file_columns_content_width, + px(0.), + )) + .scrollbar_show( + ScrollbarShow::Scrolling, + ), + ), + ) + }), + ), ), ), ), + ), ); - outer = outer.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() - .flex_1() - .min_w(px(0.)) - .overflow_hidden() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(status), - ) - .child( - Button::new("open-transfers") - .ghost() - .small() - .when(has_transfers, |this| { - let mut content = h_flex().items_center().gap_2(); - if let Some((ref label, ref pct_display, pct)) = dl_summary { - content = content.child( - h_flex() - .items_center() - .gap_1() - .child( - Icon::new(IconName::ArrowDown) - .with_size(Size::Small) - .text_color(cx.theme().primary), - ) - .child( - div() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(label.clone()), - ) - .child( - Progress::new("sftp-status-dl") - .with_size(px(4.)) - .value(pct) - .color(cx.theme().primary) - .w(px(50.0)), - ) - .child( - div() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(pct_display.clone()), - ), - ); - } - if let Some((ref label, ref pct_display, pct)) = ul_summary { - if dl_summary.is_some() { - content = content.child(div().w(px(6.))); - } - content = content.child( - h_flex() - .items_center() - .gap_1() - .child( - Icon::new(IconName::ArrowUp) - .with_size(Size::Small) - .text_color(cx.theme().primary), - ) - .child( - div() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(label.clone()), - ) - .child( - Progress::new("sftp-status-ul") - .with_size(px(4.)) - .value(pct) - .color(cx.theme().primary) - .w(px(50.0)), - ) - .child( - div() - .text_size(rems(0.833)) - .text_color(cx.theme().primary) - .italic() - .child(pct_display.clone()), - ), - ); - } - this.child(content) - }) - .when(!has_transfers, |this| { - this.icon(IconName::ArrowDown) - .label(t!("transfers").to_string()) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.show_transfers_dialog(window, cx); - })), - ) - .child( - Button::new("sftp-minimize-toggle") - .ghost() - .small() - .icon(if self.sftp_panel_minimized { - IconName::ChevronUp - } else { - IconName::ChevronDown - }) - .label(if self.sftp_panel_minimized { - t!("panel_expand").to_string() - } else { - t!("panel_minimize").to_string() - }) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_sftp_minimized(window, cx); - })), - ), - ); + outer = outer.when(self.sftp_panel_minimized, |this| { + this.child(self.render_sftp_minimized_bar(cx)) + }); outer.into_any_element() } @@ -1310,7 +2012,11 @@ impl Ashell { panel } - fn render_sidebar_monitoring_panel(&self, cx: &mut Context) -> impl IntoElement { + fn render_sidebar_monitoring_panel( + &self, + interactive: bool, + cx: &mut Context, + ) -> impl IntoElement { let cpu_pct = self.system.cpu_percent; let mem_pct = self.system.mem_percent; let swap_pct = self.system.swap_percent; @@ -1328,7 +2034,23 @@ impl Ashell { .p_2() .child( v_flex() + .id("sidebar-cpu-module") .gap_1() + .when(interactive, |this| { + this.rounded_md() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().secondary)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.show_remote_processes_dialog( + ServerMonitorView::Cpu, + window, + cx, + ) + }), + ) + }) .child( h_flex() .justify_between() @@ -1355,7 +2077,23 @@ impl Ashell { ) .child( v_flex() + .id("sidebar-memory-module") .gap_1() + .when(interactive, |this| { + this.rounded_md() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().secondary)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.show_remote_processes_dialog( + ServerMonitorView::Memory, + window, + cx, + ) + }), + ) + }) .child( h_flex() .justify_between() @@ -1495,7 +2233,19 @@ impl Ashell { ) .child( v_flex() + .id("sidebar-network-module") .gap_1() + .when(interactive, |this| { + this.rounded_md() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().secondary)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.show_remote_ports_dialog(window, cx) + }), + ) + }) .child( h_flex() .justify_between() @@ -1555,9 +2305,672 @@ impl Ashell { ) } + pub(crate) fn render_remote_process_list( + &self, + view_mode: ServerMonitorView, + cx: &mut Context, + ) -> gpui::AnyElement { + let process_filter = self + .remote_process_filter_input + .read(cx) + .value() + .trim() + .to_lowercase(); + let has_process_data = !self.remote_processes.is_empty(); + let processes = self + .remote_processes + .iter() + .filter(|process| process_matches_filter(process, &process_filter)) + .cloned() + .collect::>(); + let terminating = self.terminating_processes.clone(); + let monitored_tab_id = self.system_tab_id.clone().unwrap_or_default(); + let status = self.remote_process_status.clone(); + let processes_loading = self.remote_processes_in_flight; + let expanded_pid = self.expanded_process_pid; + let empty_message = if has_process_data { + t!("no_matching_processes").to_string() + } else { + t!("no_processes").to_string() + }; + // Use one fixed grid for the header and every process row so the + // selected metric stays aligned with its values below. + let metric_column_width = px(84.); + let action_column_width = px(28.); + let metric_label = if view_mode == ServerMonitorView::Cpu { + t!("cpu_percent").to_string() + } else { + t!("memory_usage_short").to_string() + }; + let theme = cx.theme().colors; + + v_flex() + .flex_1() + .min_h(px(0.)) + .w_full() + .gap_1() + .child( + h_flex() + .h(px(28.)) + .flex_none() + .items_center() + .px_2() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .flex_1() + .min_w(px(0.)) + .text_size(rems(0.75)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(t!("process").to_string()), + ) + .child( + div() + .w(metric_column_width) + .flex_none() + .text_size(rems(0.75)) + .text_right() + .text_color(cx.theme().muted_foreground) + .child(metric_label), + ) + .child( + div().w(action_column_width).flex_none(), + ) + .child( + div() + .w(action_column_width) + .flex_none() + .flex() + .items_center() + .justify_center() + .child( + pointer_button("refresh-processes") + .ghost() + .xsmall() + .icon(IconName::Redo) + .tooltip(t!("refresh_processes").to_string()) + .disabled(self.remote_processes_in_flight) + .on_click(cx.listener(|this, _, _, cx| { + this.request_active_process_snapshot(); + cx.notify(); + })), + ), + ), + ) + .child( + h_flex() + .h(px(34.)) + .flex_none() + .items_center() + .px_2() + .child( + Input::new(&self.remote_process_filter_input) + .flex_1() + .min_w(px(0.)), + ), + ) + .children(status.map(|status| { + div() + .w_full() + .flex_none() + .px_2() + .py_1() + .truncate() + .text_size(rems(0.75)) + .text_color(cx.theme().muted_foreground) + .child(status) + })) + .child( + div() + .relative() + .flex_1() + .min_h(px(0.)) + .w_full() + .when(processes.is_empty() && !processes_loading, |this| { + this.flex().items_center().justify_center().child( + div() + .text_size(rems(0.833)) + .text_color(theme.muted_foreground) + .child(empty_message.clone()), + ) + }) + .when(!processes.is_empty(), |this| { + this.child( + div() + .relative() + .size_full() + .child( + v_flex() + .id("remote-process-scroll") + .track_scroll(&self.process_scroll_handle) + .overflow_y_scroll() + .size_full() + .children( + processes.iter().enumerate().map(|(index, process)| { + let pid = process.pid; + let expanded = expanded_pid == Some(pid); + let is_terminating = terminating.contains(&pid); + let metric = if view_mode == ServerMonitorView::Cpu { + format!("{:.1}%", process.cpu_percent) + } else { + format_bytes(process.memory_bytes) + }; + let command = process.command.clone(); + let copy_payload = format!( + "{}: {pid}\n{}: {}\n{}: {:.2}%\n{}: {}\n{}: {}", + t!("process_pid"), + t!("user"), + process.user, + t!("cpu"), + process.cpu_percent, + t!("memory"), + format_bytes(process.memory_bytes), + t!("process_command"), + process.command + ); + let process_for_dialog = process.clone(); + let tab_id = monitored_tab_id.clone(); + let row_theme = theme; + v_flex() + .w_full() + .px_2() + .py_1() + .gap_1() + .border_b_1() + .border_color(row_theme.border.opacity(0.5)) + .when(index % 2 == 1, |this| { + this.bg(row_theme.muted.opacity(0.35)) + }) + .when(expanded, |this| { + this.bg(row_theme.secondary.opacity(0.45)) + }) + .id(("remote-process-row", pid as usize)) + .on_click(cx.listener(move |this, _, _, cx| { + this.toggle_process_expanded(pid, cx); + })) + .child( + h_flex() + .w_full() + .items_start() + .gap_2() + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .gap_0p5() + .when(!expanded, |this| { + this.child( + div() + .w_full() + .truncate() + .text_size(rems(0.833)) + .child(command.clone()), + ) + .child( + div() + .w_full() + .truncate() + .text_size(rems(0.667)) + .text_color( + row_theme + .muted_foreground, + ) + .child( + t!( + "process_summary", + name = process.user.as_str(), + pid = pid + ) + .to_string(), + ), + ) + }) + .when(expanded, |this| { + this.child( + div() + .text_size(rems(0.75)) + .text_color( + row_theme + .muted_foreground, + ) + .child( + t!("process_details") + .to_string(), + ), + ) + }), + ) + .child( + div() + .w(metric_column_width) + .flex_none() + .text_size(rems(0.75)) + .font_weight(FontWeight::SEMIBOLD) + .text_right() + .text_color( + if view_mode + == ServerMonitorView::Cpu + { + row_theme.chart_1 + } else { + row_theme.chart_2 + }, + ) + .child(metric), + ) + .child( + div() + .w(action_column_width) + .flex_none() + .flex() + .items_center() + .justify_center() + .on_mouse_down( + MouseButton::Left, + |_, _, cx| { + cx.stop_propagation(); + }, + ) + .child( + PointerClipboard::new(( + "copy-process", + pid as usize, + )) + .value(copy_payload) + .tooltip( + t!("copy_process") + .to_string(), + ), + ), + ) + .child( + div() + .w(action_column_width) + .flex_none() + .flex() + .items_center() + .justify_center() + .on_mouse_down( + MouseButton::Left, + |_, _, cx| { + cx.stop_propagation(); + }, + ) + .child( + pointer_button(( + "terminate-process", + pid as usize, + )) + .danger() + .outline() + .xsmall() + .icon(IconName::Delete) + .tooltip( + t!("terminate_process") + .to_string(), + ) + .disabled( + pid <= 1 || is_terminating, + ) + .on_click(cx.listener( + move |this, _, window, cx| { + cx.stop_propagation(); + this.show_terminate_process_dialog( + tab_id.clone(), + process_for_dialog.clone(), + window, + cx, + ) + }, + )), + ), + ), + ) + .when(expanded, |this| { + this.child( + v_flex() + .w_full() + .gap_1() + .p_2() + .rounded_md() + .bg(row_theme.background.opacity(0.6)) + .child( + div() + .whitespace_normal() + .text_size(rems(0.833)) + .child(format!( + "{}: {}", + t!("process_command"), + command + )), + ) + .child( + h_flex() + .gap_3() + .text_size(rems(0.75)) + .text_color(row_theme.muted_foreground) + .child(format!( + "{}: {}", + t!("user"), + process.user + )) + .child(format!( + "{}: {}", + t!("process_pid"), + pid + )) + .child(format!( + "{}: {:.2}%", + t!("cpu"), + process.cpu_percent + )) + .child(format!( + "{}: {}", + t!("memory"), + format_bytes(process.memory_bytes) + )), + ), + ) + }) + .into_any_element() + }), + ), + ) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(8.)) + .child( + Scrollbar::vertical(&self.process_scroll_handle) + .scrollbar_show(ScrollbarShow::Scrolling), + ), + ), + ) + }), + ) + .into_any_element() + } + + pub(crate) fn render_remote_port_list(&self, cx: &mut Context) -> gpui::AnyElement { + let port_filter = self + .remote_port_filter_input + .read(cx) + .value() + .trim() + .to_lowercase(); + let has_port_data = !self.remote_ports.is_empty(); + let ports = self + .remote_ports + .iter() + .filter(|port| port_matches_filter(port, &port_filter)) + .cloned() + .collect::>(); + let status = self.remote_ports_status.clone(); + let loading = self.remote_ports_in_flight; + let empty_message = if has_port_data { + t!("no_matching_ports").to_string() + } else { + t!("no_ports").to_string() + }; + let theme = cx.theme().colors; + + v_flex() + .flex_1() + .min_h(px(0.)) + .w_full() + .gap_1() + .child( + h_flex() + .h(px(28.)) + .flex_none() + .items_center() + .gap_2() + .px_2() + .border_b_1() + .border_color(theme.border) + .child( + div() + .flex_1() + .text_size(rems(0.75)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child(t!("network_ports").to_string()), + ) + .child( + pointer_button("refresh-ports") + .ghost() + .xsmall() + .icon(IconName::Redo) + .tooltip(t!("refresh_ports").to_string()) + .disabled(loading) + .on_click(cx.listener(|this, _, _, cx| { + this.request_active_port_snapshot(); + cx.notify(); + })), + ), + ) + .child( + h_flex().h(px(34.)).flex_none().items_center().px_2().child( + Input::new(&self.remote_port_filter_input) + .flex_1() + .min_w(px(0.)), + ), + ) + .child( + h_flex() + .h(px(24.)) + .flex_none() + .items_center() + .gap_2() + .px_2() + .text_size(rems(0.667)) + .text_color(theme.muted_foreground) + .child( + div() + .w(px(64.)) + .flex_none() + .child(t!("protocol").to_string()), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .child(t!("address").to_string()), + ) + .child(div().w(px(60.)).flex_none().child(t!("port").to_string())) + .child(div().w(px(82.)).flex_none().child(t!("state").to_string())) + .child( + div() + .w(px(150.)) + .flex_none() + .child(t!("process").to_string()), + ), + ) + .children(status.map(|status| { + div() + .w_full() + .flex_none() + .px_2() + .py_1() + .truncate() + .text_size(rems(0.75)) + .text_color(theme.muted_foreground) + .child(status) + })) + .child( + div() + .relative() + .flex_1() + .min_h(px(0.)) + .w_full() + .when(ports.is_empty() && !loading, |this| { + this.flex().items_center().justify_center().child( + div() + .text_size(rems(0.833)) + .text_color(theme.muted_foreground) + .child(empty_message.clone()), + ) + }) + .when(!ports.is_empty(), |this| { + this.child( + div() + .relative() + .size_full() + .child( + uniform_list( + "remote-port-list", + ports.len(), + move |range, _window, _cx| { + range + .filter_map(|index| { + let port = ports.get(index)?; + Some( + h_flex() + .h(px(40.)) + .w_full() + .items_center() + .gap_2() + .px_2() + .border_b_1() + .border_color(theme.border.opacity(0.5)) + .when(index % 2 == 1, |this| { + this.bg(theme.muted.opacity(0.35)) + }) + .child( + div() + .w(px(64.)) + .flex_none() + .text_size(rems(0.75)) + .child(port.protocol.clone()), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .text_size(rems(0.75)) + .child(port.address.clone()), + ) + .child( + div() + .w(px(60.)) + .flex_none() + .text_size(rems(0.75)) + .child(port.port.to_string()), + ) + .child( + div() + .w(px(82.)) + .flex_none() + .truncate() + .text_size(rems(0.75)) + .text_color( + theme.muted_foreground, + ) + .child(port.state.clone()), + ) + .child( + div() + .w(px(150.)) + .flex_none() + .truncate() + .text_size(rems(0.75)) + .text_color( + theme.muted_foreground, + ) + .child(match port.pid { + Some(pid) => t!( + "process_summary", + name = port + .process + .as_str(), + pid = pid + ) + .to_string(), + None => { + port.process.clone() + } + }), + ) + .into_any_element(), + ) + }) + .collect::>() + }, + ) + .size_full() + .track_scroll(&self.port_scroll_handle), + ) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(8.)) + .child( + Scrollbar::vertical(&self.port_scroll_handle) + .scrollbar_show(ScrollbarShow::Scrolling), + ), + ), + ) + }), + ) + .into_any_element() + } + fn sidebar(&self, cx: &mut Context) -> impl IntoElement { - let sessions = self.config.sessions().to_vec(); + let connection_filter = self + .connection_filter_input + .read(cx) + .value() + .trim() + .to_lowercase(); + let sessions = self + .config + .sessions() + .iter() + .filter(|session| connection_matches_filter(session, &connection_filter)) + .cloned() + .collect::>(); + let empty_connections_message = if self.config.sessions().is_empty() { + t!("no_connections").to_string() + } else { + t!("no_matching_connections").to_string() + }; + let has_connections = !sessions.is_empty(); + let visible_session_ids = sessions + .iter() + .map(|session| session.id.clone()) + .collect::>(); + let all_connections_selected = has_connections + && visible_session_ids + .iter() + .all(|id| self.selected_connection_ids.contains(id)); + let total_connections = self.config.sessions().len(); + let selected_connections = self + .config + .sessions() + .iter() + .filter(|session| self.selected_connection_ids.contains(&session.id)) + .count(); + let has_selected_connections = selected_connections > 0; + let saved_sessions_overflowing = self.saved_sessions_overflowing; + let saved_sessions_scroll_handle = self.saved_scroll_handle.clone(); + let sidebar_view = cx.entity(); let active_session_id = self.active_session_id().map(ToOwned::to_owned); + let is_active_ssh = matches!(self.active_kind(), Some(TabKind::Ssh)); + let is_active_ssh_connected = self + .active_tab + .as_ref() + .and_then(|active_id| self.tabs.iter().find(|tab| tab.id == *active_id)) + .is_some_and(|tab| tab.kind == TabKind::Ssh && tab.connected); v_flex() .gap_4() @@ -1586,7 +2999,7 @@ impl Ashell { ) .child(div().flex_1()) .child( - Button::new("sidebar-collapse-toggle") + pointer_button("sidebar-collapse-toggle") .ghost() .icon(IconName::PanelLeftClose) .tooltip(t!("settings_toggle_sidebar").to_string()) @@ -1598,7 +3011,7 @@ impl Ashell { })), ) .child( - Button::new("sidebar-settings") + pointer_button("sidebar-settings") .ghost() .icon(IconName::Settings) .tooltip(t!("settings_open_settings").to_string()) @@ -1611,6 +3024,7 @@ impl Ashell { div() .text_size(rems(0.917)) .text_color(cx.theme().muted_foreground) + .truncate() .child({ if let Some(kind) = self.active_kind() { match kind { @@ -1636,16 +3050,12 @@ impl Ashell { }), ), ) - .when(self.config.monitoring_position() == "Sidebar", |this| { - this.child(self.render_sidebar_monitoring_panel(cx)) + .when(is_active_ssh, |this| { + this.child(self.render_sidebar_monitoring_panel(is_active_ssh_connected, cx)) }) - .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)), - ), + .when( + !is_active_ssh && self.config.monitoring_position() == "Sidebar", + |this| this.child(self.render_sidebar_monitoring_panel(false, cx)), ) .child( v_flex() @@ -1653,24 +3063,154 @@ impl Ashell { .min_h(px(0.)) .gap_2() .child( - div() - .text_size(rems(1.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(cx.theme().primary) - .child(t!("saved")), + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .text_size(rems(1.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().primary) + .truncate() + .child(t!("connection_management")), + ) + .child( + pointer_button("import-connections") + .ghost() + .small() + .icon(IconName::ArrowDown) + .label(t!("import_connections").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.import_connections(window, cx) + })), + ) + .child( + pointer_button("export-connections") + .ghost() + .small() + .icon(IconName::ArrowUp) + .label(t!("export_connections").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.export_connections(window, cx) + })), + ) + .child( + pointer_button("open-ssh-panel") + .primary() + .small() + .icon(IconName::Plus) + .label(t!("new_connection_short").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.open_new_ssh_dialog(window, cx) + })), + ), ) .child( - div() + Input::new(&self.connection_filter_input) + .w_full() + .min_w(px(0.)), + ) + .child( + h_flex() + .w_full() + .items_center() + .gap_1() + .pl(px(5.)) + .py(px(4.)) + .child( + h_flex() + .w(px(16.)) + .flex_none() + .items_center() + .justify_center() + .child( + pointer_checkbox("connections-select-all") + .small() + .checked(all_connections_selected) + .disabled(!has_connections) + .tab_stop(false) + .on_click(cx.listener({ + let visible_session_ids = visible_session_ids.clone(); + move |this, checked, _, cx| { + if *checked { + this.select_all_connections( + visible_session_ids.clone(), + cx, + ); + } else { + for session_id in &visible_session_ids { + this.selected_connection_ids + .remove(session_id); + } + cx.notify(); + } + } + })), + ), + ) + .child( + div() + .text_size(rems(0.75)) + .text_color(cx.theme().muted_foreground) + .child(format!("{selected_connections}/{total_connections}")), + ) + .child(div().flex_1()) + .child( + pointer_button("delete-selected-connections") + .danger() + .small() + .icon(IconName::Delete) + .label(t!("delete_selected_connections").to_string()) + .disabled(!has_selected_connections) + .on_click(cx.listener(|this, _, _, cx| { + this.remove_selected_sessions(cx); + })), + ), + ) + .child( + h_flex() .relative() .flex_1() .min_h(px(0.)) - .size_full() + .w_full() + .h_full() + .when(!has_connections, |this| { + this.child( + div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .right_0() + .flex() + .items_center() + .justify_center() + .text_size(rems(0.833)) + .text_color(cx.theme().muted_foreground) + .child(empty_connections_message.clone()), + ) + }) .child( v_flex() - .size_full() + .flex_1() + .min_w(px(0.)) + .h_full() .id("saved-sessions-scroll") .track_scroll(&self.saved_scroll_handle) .overflow_y_scroll() + .on_prepaint(move |_, _, cx| { + let overflowing = + saved_sessions_scroll_handle.max_offset().y > px(0.); + sidebar_view.update(cx, |this, cx| { + if this.saved_sessions_overflowing != overflowing { + this.saved_sessions_overflowing = overflowing; + cx.notify(); + } + }); + }) .gap_2() .children(sessions.into_iter().enumerate().map( |(ix, session)| { @@ -1679,12 +3219,18 @@ impl Ashell { let delete_id = session.id.clone(); let is_active = active_session_id.as_deref() == Some(session.id.as_str()); + let is_selected = self + .selected_connection_ids + .contains(&session.id); let name = session.name.clone(); let detail = self.session_detail(&session); + let selection_id = session.id.clone(); + let row_selection_id = selection_id.clone(); div() .id(("saved-connect", ix)) .w_full() - .p_2() + .px_1() + .py_1() .rounded_md() .border_1() .border_color(if is_active { @@ -1697,15 +3243,19 @@ impl Ashell { } else { cx.theme().muted }) - .cursor_pointer() .hover(|this| this.bg(cx.theme().secondary)) + .cursor_pointer() .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, _, cx| { - this.connect_saved_session( - connect_id.clone(), + let selected = !this + .selected_connection_ids + .contains(&row_selection_id); + this.toggle_connection_selection( + row_selection_id.clone(), + selected, cx, - ) + ); }), ) .context_menu({ @@ -1761,33 +3311,125 @@ impl Ashell { } }) .child( - v_flex() - .gap_1() + h_flex() + .w_full() + .min_w(px(0.)) + .items_center() + .gap_0() + .child( + h_flex() + .w(px(16.)) + .mr_1() + .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( + pointer_checkbox(ElementId::Name( + format!( + "connection-check-{}", + selection_id + ) + .into(), + )) + .small() + .checked(is_selected) + .tab_stop(false) + .on_click(cx.listener({ + let selection_id = + selection_id.clone(); + move |this, checked, _, cx| { + this.toggle_connection_selection( + selection_id.clone(), + *checked, + cx, + ); + } + })), + ), + ) .child( div() - .text_size(rems(1.0)) + .flex_1() + .flex_basis(relative(0.5)) + .min_w(px(0.)) + .truncate() + .text_size(rems(0.833)) .font_weight(FontWeight::SEMIBOLD) .child(name), ) .child( div() - .text_size(rems(0.917)) + .flex_1() + .flex_basis(relative(0.5)) + .min_w(px(0.)) + .truncate() + .text_right() + .text_size(rems(0.75)) .text_color( cx.theme().muted_foreground, ) .child(detail), + ) + .child( + h_flex() + .w(px(24.)) + .ml_1() + .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( + pointer_button(( + "connect-saved-session", + ix, + )) + .ghost() + .xsmall() + .icon(IconName::ExternalLink) + .tooltip(t!("connect").to_string()) + .on_click(cx.listener( + move |this, _, _, cx| { + this.connect_saved_session( + connect_id.clone(), + cx, + ); + }, + )), + ), ), ) }, )), ) - .child( + .when(saved_sessions_overflowing, |this| this.child( div() - .absolute() - .top_0() - .bottom_0() - .left_0() - .right_0() + .relative() + .w(px(16.)) + .h_full() + .flex_none() .child( gpui_component::scroll::Scrollbar::new( &self.saved_scroll_handle, @@ -1798,13 +3440,25 @@ impl Ashell { gpui_component::scroll::ScrollbarShow::Always, ), ), - ), + )), ), ) } fn render_collapsed_sidebar(&self, cx: &mut Context) -> impl IntoElement { - let sessions = self.config.sessions().to_vec(); + let connection_filter = self + .connection_filter_input + .read(cx) + .value() + .trim() + .to_lowercase(); + let sessions = self + .config + .sessions() + .iter() + .filter(|session| connection_matches_filter(session, &connection_filter)) + .cloned() + .collect::>(); let active_session_id = self.active_session_id().map(ToOwned::to_owned); v_flex() @@ -1826,7 +3480,7 @@ impl Ashell { .justify_center() .pb_2() .child( - Button::new("sidebar-expand-toggle") + pointer_button("sidebar-expand-toggle") .ghost() .icon(IconName::PanelLeftOpen) .tooltip(t!("settings_toggle_sidebar").to_string()) @@ -1851,6 +3505,7 @@ impl Ashell { .id("collapsed-saved-sessions-scroll") .track_scroll(&self.collapsed_saved_scroll_handle) .overflow_y_scroll() + .pr(px(12.)) .gap_2() .items_center() .children(sessions.into_iter().enumerate().map(|(ix, session)| { @@ -1859,26 +3514,6 @@ impl Ashell { active_session_id.as_deref() == Some(session.id.as_str()); let name = session.name.clone(); - // Abbreviate: first 1 char for CJK, first 2 chars for Latin - let abbrev = { - let mut chars = name.chars(); - if let Some(first) = chars.next() { - if first > '\u{2E7F}' { - // CJK character range — show 1 char - first.to_string() - } else { - // Latin / ASCII — show first 2 chars - let mut s = first.to_string(); - if let Some(second) = chars.next() { - s.push(second); - } - s - } - } else { - "?".to_string() - } - }; - let edit_id = session.id.clone(); let delete_id = session.id.clone(); div() @@ -1900,14 +3535,7 @@ impl Ashell { } 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) - }), - ) .tooltip({ let tooltip_text = format!("{} {}", name, session.user); move |window, cx| { @@ -1964,15 +3592,14 @@ impl Ashell { } }) .child( - div() - .text_size(rems(0.833)) - .font_weight(FontWeight::BOLD) - .text_color(if is_active { - cx.theme().primary - } else { - cx.theme().foreground - }) - .child(abbrev), + pointer_button(("connect-collapsed-session", ix)) + .ghost() + .xsmall() + .icon(IconName::ExternalLink) + .tooltip(t!("connect").to_string()) + .on_click(cx.listener(move |this, _, _, cx| { + this.connect_saved_session(connect_id.clone(), cx); + })), ) })), ) @@ -1981,8 +3608,8 @@ impl Ashell { .absolute() .top_0() .bottom_0() - .left_0() .right_0() + .w(px(12.)) .child( gpui_component::scroll::Scrollbar::new( &self.collapsed_saved_scroll_handle, @@ -2133,15 +3760,19 @@ impl Ashell { } fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement { - let active_tab_index = self - .active_tab - .as_ref() - .and_then(|active_id| self.tabs.iter().position(|tab| &tab.id == active_id)); let active_group_index = self .active_group .as_ref() .and_then(|gid| self.tab_groups.iter().position(|g| g.id == *gid)); - let selected = active_group_index.unwrap_or(active_tab_index.unwrap_or(0)); + let selected = active_group_index + .or_else(|| { + self.active_tab.as_ref().and_then(|active_id| { + self.tab_groups + .iter() + .position(|group| group.pane_root.contains(active_id)) + }) + }) + .unwrap_or(0); let groups_data: Vec<(String, String, Vec)> = self .tab_groups .iter() @@ -2157,6 +3788,14 @@ impl Ashell { .collect(); let is_integrated = self.active_title_bar_style == crate::session::config::TitleBarStyle::Integrated; + let active_terminal_encoding = self.active_tab.as_ref().and_then(|active_id| { + self.tabs + .iter() + .find(|tab| { + tab.id == *active_id && matches!(tab.kind, TabKind::Local | TabKind::Ssh) + }) + .map(|tab| (tab.id.clone(), tab.text_encoding())) + }); h_flex() .flex_1() @@ -2175,6 +3814,7 @@ impl Ashell { .overflow_x_hidden() .child({ TabBar::new("ashell-tab-bar") + .underline() .track_scroll(&self.tabs_scroll_handle) .selected_index(selected) .children(groups_data.iter().enumerate().map( @@ -2206,42 +3846,65 @@ impl Ashell { .unwrap_or(cx.theme().success); Tab::new() .min_w(px(80.)) - .prefix(div().w(px(5.)).h(px(32.)).bg(dot_color)) .child( - div() - .when(ix == selected, |this| { - this.font_weight(FontWeight::BOLD) - .text_color(cx.theme().primary) - .text_base() - }) - .child(label), + h_flex() + .w_full() + .min_w(px(0.)) + .items_center() + .gap_2() + .child( + div() + .flex_none() + .size(px(6.)) + .rounded_full() + .bg(dot_color), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .when(ix == selected, |this| { + this.font_weight(FontWeight::BOLD) + .text_color(cx.theme().foreground) + }) + .when(ix != selected, |this| { + this.text_color( + cx.theme().muted_foreground, + ) + }) + .child(label), + ) + .child( + pointer_button(("tab-close", ix)) + .ghost() + .xsmall() + .icon(IconName::Close) + .mr(px(5.)) + .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(); + if !close_id.is_empty() { + this.close_tab( + close_id.clone(), + cx, + ) + } + }, + )), + ), ) .on_click(cx.listener(move |this, _, window, cx| { this.activate_group(gid.clone(), window, cx) })) - .suffix( - Button::new(("tab-close", ix)) - .ghost() - .xsmall() - .icon(IconName::Close) - .mr(px(5.)) - .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(); - if !close_id.is_empty() { - this.close_tab(close_id.clone(), cx) - } - }, - )), - ) }, )) .last_empty_space(div().w_3()) @@ -2255,19 +3918,75 @@ impl Ashell { .items_center() .gap_1() .pr(px(6.)) + .when_some(active_terminal_encoding, |this, (tab_id, encoding)| { + this.child( + pointer_button("terminal-encoding") + .ghost() + .small() + .icon(IconName::Globe) + .label(encoding.label()) + .tooltip(t!("terminal_encoding").to_string()) + .dropdown_menu_with_anchor(Anchor::BottomRight, { + let view = cx.entity(); + move |menu, window, _| { + TERMINAL_ENCODINGS.iter().copied().fold( + menu.min_w(0.), + |menu, candidate| { + let tab_id = tab_id.clone(); + menu.item( + PopupMenuItem::new(candidate.label()) + .checked(candidate == encoding) + .on_click(window.listener_for( + &view, + move |this, _, _, cx| { + this.set_terminal_encoding( + tab_id.clone(), + candidate, + cx, + ); + }, + )), + ) + }, + ) + } + }), + ) + }) .child( - Button::new("open-selector") + pointer_button("open-selector") .secondary() .small() .rounded(px(999.)) .icon(IconName::Plus) .tooltip(t!("settings_open_session").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.show_selector_dialog(window, cx) - })), + .dropdown_menu_with_anchor(Anchor::BottomRight, { + let view = cx.entity(); + move |menu, window, _cx| { + menu.min_w(0.) + .item( + PopupMenuItem::new(t!("local_terminal").to_string()) + .on_click(window.listener_for( + &view, + |this, _, _, cx| { + this.open_local(cx); + }, + )), + ) + .item( + PopupMenuItem::new(t!("new_connection").to_string()) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + this.open_new_ssh_dialog(window, cx); + }, + )), + ) + } + }), ) .child( - Button::new("split-horizontal") + pointer_button("split-horizontal") .secondary() .small() .rounded(px(999.)) @@ -2280,7 +3999,7 @@ impl Ashell { })), ) .child( - Button::new("split-vertical") + pointer_button("split-vertical") .secondary() .small() .rounded(px(999.)) @@ -2296,6 +4015,373 @@ impl Ashell { ) } + fn render_command_history_popover_content(&self, cx: &mut Context) -> impl IntoElement { + let Some(active_session_id) = self.active_tab.as_ref().and_then(|active_id| { + self.tabs + .iter() + .find(|tab| &tab.id == active_id && tab.kind == TabKind::Ssh) + .and_then(|tab| tab.session.as_ref()) + .map(|session| session.id.clone()) + }) else { + return div().into_any_element(); + }; + + let mut history_entries = self.config.all_command_history(); + let (mut active_history, other_history): (Vec<_>, Vec<_>) = history_entries + .drain(..) + .partition(|(session_id, _, _)| session_id == &active_session_id); + active_history.extend(other_history); + let mut seen_commands = HashSet::new(); + let history_entries = active_history + .into_iter() + .filter(|(_, _, command)| seen_commands.insert(command.clone())) + .collect::>(); + let total_history = history_entries.len(); + let selected_history = history_entries + .iter() + .filter(|(session_id, index, _)| { + self.selected_command_history + .contains(&(session_id.clone(), *index)) + }) + .count(); + let history_filter = self + .command_history_filter_input + .read(cx) + .value() + .trim() + .to_lowercase(); + let filtered_history = history_entries + .iter() + .filter(|(_, _, command)| { + history_filter.is_empty() || command.to_lowercase().contains(&history_filter) + }) + .cloned() + .collect::>(); + let visible_history_keys = filtered_history + .iter() + .map(|(session_id, index, _)| (session_id.clone(), *index)) + .collect::>(); + let all_history_selected = !visible_history_keys.is_empty() + && visible_history_keys + .iter() + .all(|key| self.selected_command_history.contains(key)); + let has_selected_history = selected_history > 0; + let has_visible_history = !filtered_history.is_empty(); + let history_has_overflow = filtered_history.len() > 10; + let visible_history_rows = filtered_history.len().min(10); + let history_list_height = if visible_history_rows == 0 { + px(72.) + } else { + px(visible_history_rows as f32 * 32. + + visible_history_rows.saturating_sub(1) as f32 * 8.) + }; + let theme = cx.theme().clone(); + + v_flex() + .w_full() + .p_3() + .gap_2() + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_down(MouseButton::Right, |_, _, cx| { + cx.stop_propagation(); + }) + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .child(t!("command_history")), + ) + .child( + pointer_button("close-command-history") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(t!("close_command_history").to_string()) + .on_click(cx.listener(|this, _, _, cx| { + this.close_command_history(cx); + })), + ), + ) + .child( + Input::new(&self.command_history_filter_input) + .w_full() + .min_w(px(0.)), + ) + .child( + h_flex() + .w_full() + .items_center() + .gap_1() + .pl(px(5.)) + .py(px(4.)) + .child( + h_flex() + .w(px(16.)) + .flex_none() + .items_center() + .justify_center() + .child( + pointer_checkbox("command-history-select-all") + .small() + .checked(all_history_selected) + .disabled(!has_visible_history) + .tab_stop(false) + .on_click(cx.listener({ + let visible_history_keys = visible_history_keys.clone(); + move |this, checked, _, cx| { + this.set_command_history_selection( + visible_history_keys.clone(), + *checked, + cx, + ); + } + })), + ), + ) + .child( + div() + .text_size(rems(0.75)) + .text_color(theme.muted_foreground) + .child(format!("{selected_history}/{total_history}")), + ) + .child(div().flex_1()) + .child( + pointer_button("delete-selected-command-history") + .danger() + .small() + .icon(IconName::Delete) + .label(t!("delete_selected_connections").to_string()) + .tooltip(t!("delete_selected_commands").to_string()) + .disabled(!has_selected_history) + .on_click(cx.listener(|this, _, _, cx| { + this.remove_selected_command_history(cx); + })), + ), + ) + .child( + h_flex() + .w_full() + .h(history_list_height) + .flex_none() + .min_h(px(0.)) + .child( + div() + .flex_1() + .min_w(px(0.)) + .h_full() + .id("ssh-command-history-scroll") + .track_scroll(&self.command_history_scroll_handle) + .overflow_y_scroll() + .child(if history_entries.is_empty() { + div() + .w_full() + .py_4() + .flex() + .items_center() + .justify_center() + .text_size(rems(0.833)) + .text_color(theme.muted_foreground) + .child(t!("command_history_empty")) + .into_any_element() + } else if filtered_history.is_empty() { + div() + .w_full() + .py_4() + .flex() + .items_center() + .justify_center() + .text_size(rems(0.833)) + .text_color(theme.muted_foreground) + .child(t!("no_matching_commands")) + .into_any_element() + } else { + v_flex() + .w_full() + .gap_2() + .children(filtered_history.into_iter().map( + |(history_session_id, history_index, command)| { + let row_theme = theme.clone(); + let copy_value = command.clone(); + let execute_value = command.clone(); + let selection_key = + (history_session_id.clone(), history_index); + let is_selected = + self.selected_command_history.contains(&selection_key); + let row_selection_key = selection_key.clone(); + let row_id = format!( + "ssh-command-history-row-{history_session_id}-{history_index}" + ); + let copy_id = format!( + "copy-command-{history_session_id}-{history_index}" + ); + let execute_id = format!( + "execute-command-{history_session_id}-{history_index}" + ); + let selection_id = format!( + "command-history-check-{history_session_id}-{history_index}" + ); + h_flex() + .w_full() + .h(px(32.)) + .flex_none() + .min_w(px(0.)) + .items_center() + .gap_0() + .px_1() + .rounded_md() + .border_1() + .border_color(row_theme.border.opacity(0.6)) + .bg(row_theme.muted.opacity(0.35)) + .hover(|this| { + this.bg(row_theme.secondary.opacity(0.55)) + }) + .id(ElementId::Name(row_id.into())) + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + let selected = !this + .selected_command_history + .contains(&row_selection_key); + let (session_id, index) = + row_selection_key.clone(); + this.toggle_command_history_selection( + session_id, + index, + selected, + cx, + ); + }), + ) + .child( + h_flex() + .w(px(16.)) + .mr_1() + .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( + pointer_checkbox(ElementId::Name( + selection_id.into(), + )) + .small() + .checked(is_selected) + .tab_stop(false) + .on_click(cx.listener({ + let (session_id, index) = + selection_key.clone(); + move |this, checked, _, cx| { + this.toggle_command_history_selection( + session_id.clone(), + index, + *checked, + cx, + ); + } + })), + ), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .text_size(rems(0.833)) + .text_color(row_theme.foreground) + .child(command), + ) + .child( + h_flex() + .flex_none() + .items_center() + .gap_1() + .child( + div() + .on_mouse_down( + MouseButton::Left, + |_, _, cx| cx.stop_propagation(), + ) + .on_mouse_down( + MouseButton::Right, + |_, _, cx| cx.stop_propagation(), + ) + .child( + pointer_button(ElementId::Name( + execute_id.into(), + )) + .ghost() + .xsmall() + .icon(IconName::Play) + .tooltip( + t!("execute_command") + .to_string(), + ) + .on_click(cx.listener( + move |this, _, window, cx| { + this.execute_ssh_history_command( + execute_value.clone(), + window, + cx, + ); + }, + )), + ), + ) + .child( + div() + .on_mouse_down( + MouseButton::Left, + |_, _, cx| cx.stop_propagation(), + ) + .child( + PointerClipboard::new(copy_id) + .value(copy_value) + .tooltip( + t!("copy_command").to_string(), + ), + ), + ) + ) + .into_any_element() + }, + )) + .into_any_element() + }), + ) + .when(history_has_overflow, |this| { + this.child( + div() + .relative() + .w(px(16.)) + .h_full() + .flex_none() + .child( + Scrollbar::vertical(&self.command_history_scroll_handle) + .scrollbar_show(ScrollbarShow::Always), + ), + ) + }), + ) + .into_any_element() + } + fn render_terminal_panel( &mut self, window: &mut Window, @@ -2312,7 +4398,7 @@ impl Ashell { div() .size_full() .on_prepaint(move |bounds, _window, cx| { - let _ = view.update(cx, |this, cx| { + view.update(cx, |this, cx| { if this.terminal_panel_bounds != Some(bounds) { this.terminal_panel_bounds = Some(bounds); cx.notify(); @@ -2380,33 +4466,40 @@ impl Ashell { let is_url_hovered = this .hovered_url .as_ref() - .map_or(false, |hu| hu.tab_id == *tab_id); + .is_some_and(|hu| hu.tab_id == *tab_id); let mut el = div() .size_full() .overflow_hidden() .when(is_url_hovered, |d| d.cursor_pointer()) .on_mouse_down( MouseButton::Left, - cx.listener(move |this, _, _, cx| { + cx.listener(move |this, _, window, cx| { + let active_tab_changed = + this.active_tab.as_deref() != Some(tab_id_clone2.as_str()); this.focus_pane_with_id(tab_id_clone2.clone()); + if active_tab_changed { + this.prompt_active_ssh_reconnect_if_needed(window, cx); + } cx.notify(); }), ) .child(terminal::element::TerminalElement::new( - cx.entity(), - focus_handle, - snapshot, - marked_text, - font_family, - font_size, - line_height, - cell_width, - tab_id.to_string(), - this.search_highlight_map( - tab_id, - cx.theme().danger.opacity(0.35), - cx.theme().danger.opacity(0.70), - ), + terminal::element::TerminalElementConfig { + view: cx.entity(), + focus_handle, + snapshot, + marked_text, + font_family, + font_size, + line_height, + cell_width, + tab_id: tab_id.to_string(), + search_highlights: this.search_highlight_map( + tab_id, + cx.theme().danger.opacity(0.35), + cx.theme().danger.opacity(0.70), + ), + }, )); let scrollbar = this.terminal_scrollbars.entry(tab_id.clone()).or_default(); el = el.vertical_scrollbar(scrollbar); @@ -2666,44 +4759,61 @@ impl Render for Ashell { } } - let monitoring_contents = v_flex() - .size_full() - .when(self.config.monitoring_position() == "Bottom", |this| { - this.child(self.render_monitoring_panel(window.viewport_size().width, cx)) - }) - .child(self.render_sftp_panel(window, cx)); - + let has_ssh_session = self.active_ssh_session().is_some(); let is_monitor_bottom = self.config.monitoring_position() == "Bottom"; - let minimized_height = if is_monitor_bottom { 104. } else { 24. }; - let min_panel_height = if is_monitor_bottom { 260. } else { 180. }; - let default_panel_height = if is_monitor_bottom { 328. } else { 248. }; + let viewport_width = window.viewport_size().width; - let sftp_size = if self.sftp_panel_minimized { - px(minimized_height) + let body_panel = if has_ssh_session { + let minimized_height = 24.; + let min_panel_height = 180.; + let default_panel_height = 248.; + + let sftp_size = if self.sftp_panel_minimized { + px(minimized_height) + } else { + px(self + .config + .body_panels() + .and_then(|s| s.get(1).copied()) + .unwrap_or(default_panel_height)) + }; + + let view = cx.entity(); + v_resizable("ashell-body") + .lock(self.config.lock_layout()) + .with_state(&self.body_panels) + .on_resize(move |_, _, cx| { + view.update(cx, |this, _| { + this.is_layout_reset = false; + }); + }) + .child(resizable_panel().child(self.render_terminal_panel(window, cx))) + .child( + resizable_panel() + .size(sftp_size) + .size_range(if self.sftp_panel_minimized { + px(minimized_height)..px(minimized_height) + } else { + px(min_panel_height)..px(1200.) + }) + .child(self.render_sftp_panel(window, cx)), + ) + .into_any_element() } else { - px(self - .config - .body_panels() - .and_then(|s| s.get(1).copied()) - .unwrap_or(default_panel_height)) + v_flex() + .size_full() + .child( + div() + .flex_1() + .min_h(px(0.)) + .child(self.render_terminal_panel(window, cx)), + ) + .when(is_monitor_bottom, |this| { + this.child(self.render_monitoring_panel(viewport_width, cx)) + }) + .into_any_element() }; - let body_panel = v_resizable("ashell-body") - .lock(self.config.lock_layout()) - .with_state(&self.body_panels) - .child(resizable_panel().child(self.render_terminal_panel(window, cx))) - .child( - resizable_panel() - .size(sftp_size) - .size_range(if self.sftp_panel_minimized { - px(minimized_height)..px(minimized_height) - } else { - px(min_panel_height)..px(1200.) - }) - .child(monitoring_contents), - ) - .into_any_element(); - let workspace = if self.sidebar_collapsed { h_flex() .size_full() @@ -2775,9 +4885,15 @@ impl Render for Ashell { .child(body_panel), ); + let view = cx.entity(); h_resizable("ashell-workspace") .lock(self.config.lock_layout()) .with_state(&self.workspace_panels) + .on_resize(move |_, _, cx| { + view.update(cx, |this, _| { + this.is_layout_reset = false; + }); + }) .child(sidebar_area) .child(main_area) .into_any_element() @@ -2792,10 +4908,11 @@ impl Render for Ashell { .on_action(cx.listener(|this, _: &crate::OpenSettings, window, cx| this.show_settings_dialog(window, cx))) .on_action(cx.listener(|this, _: &crate::OpenSession, window, cx| this.show_selector_dialog(window, cx))) .on_action(cx.listener(|this, _: &crate::OpenTransfers, window, cx| this.show_transfers_dialog(window, cx))) - .on_action(cx.listener(|this, _: &crate::NewSsh, window, cx| this.show_ssh_dialog(window, cx))) + .on_action(cx.listener(|this, _: &crate::NewSsh, window, cx| this.open_new_ssh_dialog(window, cx))) .on_action(cx.listener(|this, _: &crate::OpenSearch, window, cx| this.toggle_search(window, cx))) .on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| { this.sidebar_collapsed = !this.sidebar_collapsed; + this.is_layout_reset = false; this.config.set_sidebar_collapsed(this.sidebar_collapsed); this.save_preferences_background(); cx.notify(); @@ -2803,10 +4920,10 @@ impl Render for Ashell { .on_action(cx.listener(|this, _: &crate::ToggleSftpZoom, window, cx| { this.toggle_sftp_minimized(window, cx); })) - .on_action(cx.listener(|this, _: &crate::FocusPaneLeft, _, cx| this.focus_adjacent_pane("left", cx))) - .on_action(cx.listener(|this, _: &crate::FocusPaneRight, _, cx| this.focus_adjacent_pane("right", cx))) - .on_action(cx.listener(|this, _: &crate::FocusPaneUp, _, cx| this.focus_adjacent_pane("up", cx))) - .on_action(cx.listener(|this, _: &crate::FocusPaneDown, _, cx| this.focus_adjacent_pane("down", cx))) + .on_action(cx.listener(|this, _: &crate::FocusPaneLeft, window, cx| this.focus_adjacent_pane("left", window, cx))) + .on_action(cx.listener(|this, _: &crate::FocusPaneRight, window, cx| this.focus_adjacent_pane("right", window, cx))) + .on_action(cx.listener(|this, _: &crate::FocusPaneUp, window, cx| this.focus_adjacent_pane("up", window, cx))) + .on_action(cx.listener(|this, _: &crate::FocusPaneDown, window, cx| this.focus_adjacent_pane("down", window, cx))) .on_action(cx.listener(|this, _: &crate::SplitPaneLeft, _, cx| this.split_current_pane("left", cx))) .on_action(cx.listener(|this, _: &crate::SplitPaneRight, _, cx| this.split_current_pane("right", cx))) .on_action(cx.listener(|this, _: &crate::SplitPaneUp, _, cx| this.split_current_pane("up", cx))) @@ -2898,11 +5015,22 @@ impl Render for Ashell { .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 { + let download_label = if menu.is_dir { t!("download_folder").to_string() } else { t!("download").to_string() }; + let edit_label = t!("edit_file").to_string(); + let rename_label = t!("rename").to_string(); + let menu_width = if menu.is_dir { + compact_menu_width(&[download_label.as_str(), rename_label.as_str()]) + } else { + compact_menu_width(&[ + download_label.as_str(), + edit_label.as_str(), + rename_label.as_str(), + ]) + }; this.child( div() .absolute() @@ -2927,7 +5055,7 @@ impl Render for Ashell { .absolute() .left(menu.position.x) .top(menu.position.y) - .w(px(172.)) + .w(menu_width) .p_1() .rounded_md() .border_1() @@ -2946,33 +5074,41 @@ impl Render for Ashell { v_flex() .w_full() .child( - Button::new("sftp-context-download") + pointer_button("sftp-context-download") .ghost() .w_full() .justify_start() - .label(label) + .label(download_label) .on_click(cx.listener(|this, _, window, cx| { this.trigger_sftp_context_download(window, cx); })), ) - .when( - !menu.is_dir - && is_editable_text_file(&menu.remote_path), - |this| { - this.child( - Button::new("sftp-context-edit") - .ghost() - .w_full() - .justify_start() - .label(t!("edit_file")) - .tooltip( - t!("edit_file_tooltip").to_string(), - ) - .on_click(cx.listener(|this, _, _, cx| { - this.trigger_sftp_context_edit(cx); - })), - ) - }, + .when(!menu.is_dir, |this| { + this.child( + pointer_button("sftp-context-edit") + .ghost() + .w_full() + .justify_start() + .label(edit_label) + .tooltip(t!("edit_file_tooltip").to_string()) + .on_click(cx.listener( + |this, _, window, cx| { + this.trigger_sftp_context_edit( + window, cx, + ); + }, + )), + ) + }) + .child( + pointer_button("sftp-context-rename") + .ghost() + .w_full() + .justify_start() + .label(rename_label) + .on_click(cx.listener(|this, _, window, cx| { + this.trigger_sftp_context_rename(window, cx); + })), ), ), ), @@ -3008,7 +5144,7 @@ impl Render for Ashell { v_flex() .gap_4() .child( - Button::new("ssh-connect-progress") + pointer_button("ssh-connect-progress") .primary() .loading(!progress.failed) .label(progress.title.clone()), @@ -3059,7 +5195,7 @@ impl Render for Ashell { .justify_end() .gap_2() .child( - Button::new("ssh-connect-progress-retry") + pointer_button("ssh-connect-progress-retry") .primary() .label(t!("retry").to_string()) .on_click(cx.listener( @@ -3071,7 +5207,7 @@ impl Render for Ashell { )), ) .child( - Button::new("ssh-connect-progress-close") + pointer_button("ssh-connect-progress-close") .label(t!("cancel").to_string()) .on_click(cx.listener( |this, _, _, cx| { @@ -3092,7 +5228,9 @@ impl Render for Ashell { move |_, window, cx| { view.update(cx, |this, cx| { let current_win_size = window.viewport_size(); - let size_changed = this.last_window_size.map_or(true, |prev| prev != current_win_size); + let size_changed = this + .last_window_size + .is_none_or(|prev| prev != current_win_size); this.last_window_size = Some(current_win_size); let current_sizes = this.workspace_panels.read(cx).sizes().clone();