diff --git a/locales/en.yml b/locales/en.yml index 751bf4b..1a43dcd 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -133,6 +133,7 @@ press_new_key: "Press new key..." settings_open_settings: "Open Settings" settings_open_session: "Open Session" settings_new_ssh: "New SSH" +settings_toggle_sidebar: "Toggle Sidebar" settings_toggle_sftp_zoom: "Toggle SFTP Zoom" settings_focus_pane_left: "Focus Pane Left" settings_focus_pane_right: "Focus Pane Right" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index b69dc3b..cc6c060 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -136,6 +136,7 @@ press_new_key: "请按下新的快捷键..." settings_open_settings: "打开设置" settings_open_session: "打开会话" settings_new_ssh: "新建 SSH" +settings_toggle_sidebar: "缩放侧边栏" settings_toggle_sftp_zoom: "缩放 SFTP 面板" settings_focus_pane_left: "向左切换焦点" settings_focus_pane_right: "向右切换焦点" diff --git a/src/app/constants.rs b/src/app/constants.rs index c34ce7e..bbeb7c9 100644 --- a/src/app/constants.rs +++ b/src/app/constants.rs @@ -1,6 +1,7 @@ pub(crate) const DEFAULT_COLS: u16 = 100; pub(crate) const DEFAULT_ROWS: u16 = 30; pub(crate) const SIDEBAR_WIDTH: f32 = 306.0; +pub(crate) const COLLAPSED_SIDEBAR_WIDTH: f32 = 52.0; pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0; pub(crate) const TERMINAL_PADDING_X: f32 = 32.0; pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0; diff --git a/src/app/keybinding_recorder.rs b/src/app/keybinding_recorder.rs index 758e162..8269404 100644 --- a/src/app/keybinding_recorder.rs +++ b/src/app/keybinding_recorder.rs @@ -17,6 +17,7 @@ gpui::actions!( OpenSettings, OpenSession, NewSsh, + ToggleSidebar, ToggleSftpZoom, FocusPaneLeft, FocusPaneRight, @@ -55,6 +56,11 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[ label_key: "settings_new_ssh", default_suffix: "n", }, + WorkspaceAction { + id: "ToggleSidebar", + label_key: "settings_toggle_sidebar", + default_suffix: "s", + }, WorkspaceAction { id: "ToggleSftpZoom", label_key: "settings_toggle_sftp_zoom", @@ -193,6 +199,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) { unbind_action!("OpenSettings", crate::OpenSettings); unbind_action!("OpenSession", crate::OpenSession); unbind_action!("NewSsh", crate::NewSsh); + unbind_action!("ToggleSidebar", crate::ToggleSidebar); unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom); unbind_action!("FocusPaneLeft", crate::FocusPaneLeft); unbind_action!("FocusPaneRight", crate::FocusPaneRight); @@ -246,6 +253,7 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) { bind_action!("OpenSettings", crate::OpenSettings); bind_action!("OpenSession", crate::OpenSession); bind_action!("NewSsh", crate::NewSsh); + bind_action!("ToggleSidebar", crate::ToggleSidebar); bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom); bind_action!("FocusPaneLeft", crate::FocusPaneLeft); bind_action!("FocusPaneRight", crate::FocusPaneRight); diff --git a/src/app/mod.rs b/src/app/mod.rs index 5e7a839..c406671 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -245,6 +245,8 @@ pub(crate) struct Ashell { pub(crate) drag_split_origin: Option>, pub(crate) terminal_marked_text: Option, pub(crate) sftp_panel_minimized: bool, + pub(crate) sidebar_collapsed: bool, + pub(crate) collapsed_saved_scroll_handle: gpui::ScrollHandle, pub(crate) prev_monitoring_size: Option, pub(crate) status: SharedString, pub(crate) config: ConfigStore, @@ -467,6 +469,8 @@ impl Ashell { dragging_splitter: None, drag_split_origin: None, sftp_panel_minimized: false, + sidebar_collapsed: false, + collapsed_saved_scroll_handle: gpui::ScrollHandle::new(), prev_monitoring_size: None, status: "ready".into(), config, diff --git a/src/app/ui.rs b/src/app/ui.rs index de74897..0f6685f 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -21,7 +21,7 @@ use rust_i18n::t; use crate::{ Ashell, PaneLayout, - app::constants::{SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT}, + app::constants::{COLLAPSED_SIDEBAR_WIDTH, SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT}, sftp::format_mtime, sftp::ops::is_editable_text_file, system::format_bytes, @@ -1516,6 +1516,15 @@ impl Ashell { .child("Ashell"), ) .child(div().flex_1()) + .child( + Button::new("sidebar-collapse-toggle") + .ghost() + .icon(IconName::PanelLeftClose) + .on_click(cx.listener(|this, _, _, cx| { + this.sidebar_collapsed = true; + cx.notify(); + })), + ) .child( Button::new("sidebar-settings") .ghost() @@ -1714,6 +1723,151 @@ impl Ashell { ) } + fn render_collapsed_sidebar(&self, cx: &mut Context) -> impl IntoElement { + let sessions = self.config.sessions().to_vec(); + let active_session_id = self.active_session_id().map(ToOwned::to_owned); + + v_flex() + .w_full() + .h_full() + .min_w(px(0.)) + .p_2() + .border_r_1() + .border_color(cx.theme().sidebar_border) + .bg(cx.theme().sidebar) + .overflow_hidden() + .items_center() + // Top: expand button only + .child( + div() + .w_full() + .flex() + .items_center() + .justify_center() + .pb_2() + .child( + Button::new("sidebar-expand-toggle") + .ghost() + .icon(IconName::PanelLeftOpen) + .on_click(cx.listener(|this, _, _, cx| { + this.sidebar_collapsed = false; + cx.notify(); + })), + ), + ) + // Saved sessions as compact cards + .child( + div() + .relative() + .flex_1() + .min_h(px(0.)) + .w_full() + .child( + v_flex() + .size_full() + .id("collapsed-saved-sessions-scroll") + .track_scroll(&self.collapsed_saved_scroll_handle) + .overflow_y_scroll() + .gap_2() + .items_center() + .children(sessions.into_iter().enumerate().map( + |(ix, session)| { + let connect_id = session.id.clone(); + let is_active = 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() + } + }; + + div() + .id(("collapsed-saved", ix)) + .w(px(36.)) + .h(px(36.)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .border_1() + .border_color(if is_active { + cx.theme().primary + } else { + cx.theme().border + }) + .bg(if is_active { + cx.theme().tab_active + } else { + cx.theme().muted + }) + .cursor_pointer() + .hover(|this| this.bg(cx.theme().secondary)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.connect_saved_session( + connect_id.clone(), + cx, + ) + }), + ) + .tooltip({ + let tooltip_text = format!("{} {}", name, session.user); + move |window, cx| { + gpui_component::tooltip::Tooltip::new(tooltip_text.clone()).build(window, cx) + } + }) + .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), + ) + }, + )), + ) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .right_0() + .child( + gpui_component::scroll::Scrollbar::new( + &self.collapsed_saved_scroll_handle, + ) + .id("collapsed-saved-scrollbar") + .axis(gpui_component::scroll::ScrollbarAxis::Vertical) + .scrollbar_show( + gpui_component::scroll::ScrollbarShow::Scrolling, + ), + ), + ), + ) + } + fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement { let active_tab_index = self .active_tab @@ -2187,16 +2341,6 @@ impl Render for Ashell { } } - let sidebar_area = resizable_panel() - .size(px(self - .config - .workspace_panels() - .and_then(|s| s.first().copied()) - .unwrap_or(SIDEBAR_WIDTH))) - .size_range(px(240.)..px(520.)) - .flex_none() - .child(self.sidebar(cx)); - let monitoring_contents = v_flex() .size_full() .when(self.config.monitoring_position() == "Bottom", |this| { @@ -2236,19 +2380,57 @@ impl Render for Ashell { ) .into_any_element(); - let main_area = resizable_panel().child( - v_flex() + let workspace = if self.sidebar_collapsed { + h_flex() .size_full() - .relative() - .overflow_hidden() - .child(self.render_tab_bar(cx)) - .child(body_panel), - ); + .child( + div() + .flex_none() + .w(px(COLLAPSED_SIDEBAR_WIDTH)) + .h_full() + .child(self.render_collapsed_sidebar(cx)), + ) + .child( + div() + .flex_1() + .h_full() + .min_w(px(0.)) + .child( + v_flex() + .size_full() + .relative() + .overflow_hidden() + .child(self.render_tab_bar(cx)) + .child(body_panel), + ), + ) + .into_any_element() + } else { + let sidebar_area = resizable_panel() + .size(px(self + .config + .workspace_panels() + .and_then(|s| s.first().copied()) + .unwrap_or(SIDEBAR_WIDTH))) + .size_range(px(240.)..px(520.)) + .flex_none() + .child(self.sidebar(cx)); - let workspace = h_resizable("ashell-workspace") - .with_state(&self.workspace_panels) - .child(sidebar_area) - .child(main_area); + let main_area = resizable_panel().child( + v_flex() + .size_full() + .relative() + .overflow_hidden() + .child(self.render_tab_bar(cx)) + .child(body_panel), + ); + + h_resizable("ashell-workspace") + .with_state(&self.workspace_panels) + .child(sidebar_area) + .child(main_area) + .into_any_element() + }; div() .id("ashell-root") @@ -2259,6 +2441,10 @@ 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::NewSsh, window, cx| this.show_ssh_dialog(window, cx))) + .on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| { + this.sidebar_collapsed = !this.sidebar_collapsed; + cx.notify(); + })) .on_action(cx.listener(|this, _: &crate::ToggleSftpZoom, window, cx| { this.toggle_sftp_minimized(window, cx); })) diff --git a/src/main.rs b/src/main.rs index 0082e64..683918d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]); pub(crate) use app::keybinding_recorder::{ ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession, OpenSettings, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp, ToggleSftpZoom, + ToggleSidebar, }; pub(crate) use app::{