From bd376935f4100c03438ae085e7bcaeb38eaad310 Mon Sep 17 00:00:00 2001 From: TomZz Date: Mon, 15 Jun 2026 20:32:05 +0800 Subject: [PATCH] feat: add keybinding management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决了 #18。 验证: - cargo fmt - cargo check - git diff --check --- locales/en.yml | 16 + locales/zh-CN.yml | 16 + src/app/dialogs.rs | 780 +++++++++++++++------------ src/app/keybinding_recorder.rs | 334 ++++++++++++ src/app/mod.rs | 77 ++- src/app/startup.rs | 88 ++-- src/app/theme.rs | 36 +- src/app/ui.rs | 934 ++++++++++++++++++++------------- src/backend/ssh.rs | 41 +- src/main.rs | 20 +- src/session/config.rs | 12 + src/session/mod.rs | 208 +++++--- src/sftp/mod.rs | 58 +- src/sftp/ops.rs | 69 ++- src/terminal/custom_blocks.rs | 166 ++++-- src/terminal/element.rs | 34 +- src/terminal/input.rs | 17 +- src/terminal/mod.rs | 8 +- 18 files changed, 1962 insertions(+), 952 deletions(-) create mode 100644 src/app/keybinding_recorder.rs diff --git a/locales/en.yml b/locales/en.yml index 9f7b973..e5aec61 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -125,3 +125,19 @@ theme: "Theme" theme_mode: "Theme Mode" clear_transfers: "Clear History" transfers_limit: " (Max 100 records)" + +press_new_key: "Press new key..." +settings_open_settings: "Open Settings" +settings_open_session: "Open Session" +settings_new_ssh: "New SSH" +settings_toggle_sftp_zoom: "Toggle SFTP Zoom" +settings_focus_pane_left: "Focus Pane Left" +settings_focus_pane_right: "Focus Pane Right" +settings_focus_pane_up: "Focus Pane Up" +settings_focus_pane_down: "Focus Pane Down" +settings_split_pane_left: "Split Pane Left" +settings_split_pane_right: "Split Pane Right" +settings_split_pane_up: "Split Pane Up" +settings_split_pane_down: "Split Pane Down" +settings_close_pane: "Close Pane" +keybind_conflict: "\"%{key}\" is already used by \"%{action}\", please choose another key" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index ae25d27..09a2336 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -128,3 +128,19 @@ theme: "主题" theme_mode: "主题模式" clear_transfers: "清空记录" transfers_limit: " (最多保留 100 条)" + +press_new_key: "请按下新的快捷键..." +settings_open_settings: "打开设置" +settings_open_session: "打开会话" +settings_new_ssh: "新建 SSH" +settings_toggle_sftp_zoom: "缩放 SFTP 面板" +settings_focus_pane_left: "向左切换焦点" +settings_focus_pane_right: "向右切换焦点" +settings_focus_pane_up: "向上切换焦点" +settings_focus_pane_down: "向下切换焦点" +settings_split_pane_left: "向左拆分面板" +settings_split_pane_right: "向右拆分面板" +settings_split_pane_up: "向上拆分面板" +settings_split_pane_down: "向下拆分面板" +settings_close_pane: "关闭当前面板" +keybind_conflict: "\"%{key}\" 已被「%{action}」使用,请选择其他快捷键" diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index d636f16..9aa6cf6 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -1,7 +1,7 @@ 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, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton, + ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, + prelude::FluentBuilder as _, px, rems, }; use gpui_component::{ ActiveTheme as _, Disableable as _, IconName, Sizable as _, WindowExt as _, @@ -17,11 +17,7 @@ use gpui_component::{ }; use rust_i18n::t; -use crate::{ - Ashell, - session::config::AuthMethod, - system::format_bytes, -}; +use crate::{Ashell, session::config::AuthMethod, system::format_bytes}; impl Ashell { pub(crate) fn show_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context) { @@ -110,24 +106,35 @@ impl Ashell { .cursor_pointer() .on_mouse_down( MouseButton::Left, - window.listener_for(&view, |this, _, window, cx| { - this.pick_ssh_key_path(window, cx); - }), + window.listener_for( + &view, + |this, _, window, cx| { + this.pick_ssh_key_path(window, cx); + }, + ), ) - .child(Input::new(&key_path_input).tab_index(5)), + .child( + Input::new(&key_path_input).tab_index(5), + ), ) .child( Button::new("clear-key-path") .ghost() .icon(IconName::Close) - .on_click(window.listener_for(&view, |this, _, window, cx| { - Self::set_input_value(&this.key_path_input, "", window, cx); - })) + .on_click(window.listener_for( + &view, + |this, _, window, cx| { + Self::set_input_value( + &this.key_path_input, + "", + window, + cx, + ); + }, + )), ), ) - .child( - Input::new(&key_inline_input).h(px(128.)).tab_index(6), - ) + .child(Input::new(&key_inline_input).h(px(128.)).tab_index(6)) }) .child( h_flex() @@ -397,346 +404,334 @@ impl Ashell { pub(crate) fn show_transfers_dialog(&mut self, window: &mut Window, cx: &mut Context) { let view = cx.entity(); window.open_dialog(cx, move |dialog: Dialog, _window, _| { - dialog - .w(px(600.)) - .close_button(false) - .content({ - let view = view.clone(); - move |content, window, cx| { - let can_clear = view.read(cx).transfers.iter().any(|t| { - !matches!( - t.state, - crate::terminal::TransferState::Running - | crate::terminal::TransferState::Paused - ) - }); - - let clear_btn = if can_clear { - Some( - Button::new("clear_transfers_btn") - .small() - .ghost() - .icon(IconName::Delete) - .label(t!("clear_transfers").to_string()) - .on_click(window.listener_for(&view, |this, _, _, cx| { - this.transfers.retain(|t| { - matches!( - t.state, - crate::terminal::TransferState::Running - | crate::terminal::TransferState::Paused - ) - }); - this.config.set_transfers(this.transfers.clone()); - cx.notify(); - })), - ) - } else { - None - }; - - let header = h_flex() - .w_full() - .justify_between() - .items_center() - .child( - h_flex() - .items_baseline() - .child( - div() - .text_lg() - .font_weight(FontWeight::SEMIBOLD) - .child(t!("transfers").to_string()), - ) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .ml_2() - .child(t!("transfers_limit").to_string()), - ), - ) - .child( - h_flex() - .gap_2() - .children(clear_btn) - .child( - Button::new("close_dialog") - .small() - .ghost() - .icon(IconName::Close) - .on_click(|_, window, cx| { - window.close_dialog(cx); - }), - ), - ); - - let mut transfers = view.read(cx).transfers.clone(); - transfers.sort_by_key(|t| match t.state { + dialog.w(px(600.)).close_button(false).content({ + let view = view.clone(); + move |content, window, cx| { + let can_clear = view.read(cx).transfers.iter().any(|t| { + !matches!( + t.state, crate::terminal::TransferState::Running - | crate::terminal::TransferState::Paused => 0, - _ => 1, - }); + | crate::terminal::TransferState::Paused + ) + }); - if transfers.is_empty() { - return content.child( - v_flex().gap_2().child(header).child( - div() - .p_4() - .text_center() - .text_color(cx.theme().muted_foreground) - .child(t!("no_transfers_yet").to_string()), - ), - ); - } - let list = v_flex().gap_2().children(transfers.into_iter().map(|t| { - let (icon, _color) = match t.info.kind { - crate::terminal::TransferType::Upload => { - (IconName::ArrowUp, cx.theme().primary) - } - crate::terminal::TransferType::Download => { - (IconName::ArrowDown, cx.theme().success) - } - }; - - let (status_text, actions) = match t.state { - crate::terminal::TransferState::Running => { - let percent = t - .total - .map(|tot| { - (t.transferred as f64 / tot as f64 * 100.0) - .clamp(0.0, 100.0) - }) - .unwrap_or(0.0); - let txt = if let Some(tot) = t.total { - format!( - "{:.1}% ({}/{})", - percent, - format_bytes(t.transferred), - format_bytes(tot) + let clear_btn = if can_clear { + Some( + Button::new("clear_transfers_btn") + .small() + .ghost() + .icon(IconName::Delete) + .label(t!("clear_transfers").to_string()) + .on_click(window.listener_for(&view, |this, _, _, cx| { + this.transfers.retain(|t| { + matches!( + t.state, + crate::terminal::TransferState::Running + | crate::terminal::TransferState::Paused ) - } else { - format!("{}...", t!("downloading")) - }; - let btn_pause = Button::new(SharedString::from(format!( - "pause-{}", - t.info.id - ))) - .ghost() - .small() - .icon(IconName::Pause) - .on_click(window.listener_for(&view, { - let id = t.info.id.clone(); - 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::Completed => { - let txt = t!("completed").to_string(); - let mut actions = h_flex().gap_1(); - if matches!( - t.info.kind, - crate::terminal::TransferType::Download - ) { - let btn_folder = Button::new(SharedString::from(format!( - "folder-{}", - t.info.id - ))) - .ghost() - .small() - .icon(IconName::Folder) - .on_click({ - let target = t.info.target.clone(); - move |_, _, _| { - let _ = std::process::Command::new("open") - .arg(&target) - .spawn(); - } - }); - actions = actions.child(btn_folder); - } - (txt, actions) - } - crate::terminal::TransferState::Failed(ref err) => { - (format!("{}: {}", t!("failed"), err), h_flex().gap_1()) - } - crate::terminal::TransferState::Cancelled => { - (t!("cancelled").to_string(), h_flex().gap_1()) - } - }; + }); + this.config.set_transfers(this.transfers.clone()); + cx.notify(); + })), + ) + } else { + None + }; - let percent = match t.state { - crate::terminal::TransferState::Completed => 100.0, - _ => t - .total - .map(|tot| t.transferred as f64 / tot as f64 * 100.0) - .unwrap_or(0.0), - }; - - v_flex() - .gap_1() - .p_2() - .rounded_md() - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().muted) + let header = h_flex() + .w_full() + .justify_between() + .items_center() + .child( + h_flex() + .items_baseline() .child( - h_flex() - .items_center() - .gap_2() - .child( - Button::new(SharedString::from(format!( - "icon-{}", - t.info.id - ))) - .icon(icon) - .ghost() - .small() - .disabled(true), - ) - .child( - v_flex() - .flex_1() - .min_w(px(0.)) - .overflow_hidden() - .child( - div() - .text_size(px(12.)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(cx.theme().foreground) - .overflow_hidden() - .child(t.info.name.clone()), - ) - .child( - div() - .text_size(px(10.)) - .text_color(cx.theme().muted_foreground) - .overflow_hidden() - .child(format!( - "{}: {}", - t!("session"), - t.tab_title - )), - ), - ) - .child( - div() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - .child(status_text), - ) - .child(actions), + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .child(t!("transfers").to_string()), ) - .when( - matches!( - t.state, - crate::terminal::TransferState::Running - | crate::terminal::TransferState::Paused - ), - |this| { - this.child( - Progress::new(format!("progress-{}", t.info.id)) - .with_size(px(4.)) - .value(percent as f32) - .color(cx.theme().primary) - .w_full(), - ) - }, - ) - })); + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .ml_2() + .child(t!("transfers_limit").to_string()), + ), + ) + .child( + h_flex().gap_2().children(clear_btn).child( + Button::new("close_dialog") + .small() + .ghost() + .icon(IconName::Close) + .on_click(|_, window, cx| { + window.close_dialog(cx); + }), + ), + ); + let mut transfers = view.read(cx).transfers.clone(); + transfers.sort_by_key(|t| match t.state { + crate::terminal::TransferState::Running + | crate::terminal::TransferState::Paused => 0, + _ => 1, + }); - let scroll_handle = window - .use_keyed_state("transfers-scroll", cx, |_, _| { - gpui::ScrollHandle::default() - }) - .read(cx) - .clone(); - - content.child( + if transfers.is_empty() { + return content.child( v_flex().gap_2().child(header).child( div() - .w_full() - .relative() + .p_4() + .text_center() + .text_color(cx.theme().muted_foreground) + .child(t!("no_transfers_yet").to_string()), + ), + ); + } + let list = v_flex().gap_2().children(transfers.into_iter().map(|t| { + let (icon, _color) = match t.info.kind { + crate::terminal::TransferType::Upload => { + (IconName::ArrowUp, cx.theme().primary) + } + crate::terminal::TransferType::Download => { + (IconName::ArrowDown, cx.theme().success) + } + }; + + let (status_text, actions) = match t.state { + crate::terminal::TransferState::Running => { + let percent = t + .total + .map(|tot| { + (t.transferred as f64 / tot as f64 * 100.0) + .clamp(0.0, 100.0) + }) + .unwrap_or(0.0); + let txt = if let Some(tot) = t.total { + format!( + "{:.1}% ({}/{})", + percent, + format_bytes(t.transferred), + format_bytes(tot) + ) + } else { + format!("{}...", t!("downloading")) + }; + let btn_pause = + Button::new(SharedString::from(format!("pause-{}", t.info.id))) + .ghost() + .small() + .icon(IconName::Pause) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + 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::Completed => { + let txt = t!("completed").to_string(); + let mut actions = h_flex().gap_1(); + if matches!(t.info.kind, crate::terminal::TransferType::Download) { + let btn_folder = Button::new(SharedString::from(format!( + "folder-{}", + t.info.id + ))) + .ghost() + .small() + .icon(IconName::Folder) + .on_click({ + let target = t.info.target.clone(); + move |_, _, _| { + let _ = std::process::Command::new("open") + .arg(&target) + .spawn(); + } + }); + actions = actions.child(btn_folder); + } + (txt, actions) + } + crate::terminal::TransferState::Failed(ref err) => { + (format!("{}: {}", t!("failed"), err), h_flex().gap_1()) + } + crate::terminal::TransferState::Cancelled => { + (t!("cancelled").to_string(), h_flex().gap_1()) + } + }; + + let percent = match t.state { + crate::terminal::TransferState::Completed => 100.0, + _ => t + .total + .map(|tot| t.transferred as f64 / tot as f64 * 100.0) + .unwrap_or(0.0), + }; + + v_flex() + .gap_1() + .p_2() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().muted) + .child( + h_flex() + .items_center() + .gap_2() .child( - div() - .w_full() - .max_h(px(400.)) - .flex_col() - .id("transfers-scroll-view") - .track_scroll(&scroll_handle) - .overflow_y_scroll() - .pr(px(14.)) - .child(list), + Button::new(SharedString::from(format!( + "icon-{}", + t.info.id + ))) + .icon(icon) + .ghost() + .small() + .disabled(true), + ) + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .overflow_hidden() + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .overflow_hidden() + .child(t.info.name.clone()), + ) + .child( + div() + .text_size(px(10.)) + .text_color(cx.theme().muted_foreground) + .overflow_hidden() + .child(format!( + "{}: {}", + t!("session"), + t.tab_title + )), + ), ) .child( div() - .absolute() - .top_0() - .right_0() - .bottom_0() - .w(px(16.)) - .child( - Scrollbar::vertical(&scroll_handle) - .scrollbar_show(ScrollbarShow::Always), - ), - ), + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(status_text), + ) + .child(actions), ) - ) - } - }) + .when( + matches!( + t.state, + crate::terminal::TransferState::Running + | crate::terminal::TransferState::Paused + ), + |this| { + this.child( + Progress::new(format!("progress-{}", t.info.id)) + .with_size(px(4.)) + .value(percent as f32) + .color(cx.theme().primary) + .w_full(), + ) + }, + ) + })); + + let scroll_handle = window + .use_keyed_state("transfers-scroll", cx, |_, _| { + gpui::ScrollHandle::default() + }) + .read(cx) + .clone(); + + content.child( + v_flex().gap_2().child(header).child( + div() + .w_full() + .relative() + .child( + div() + .w_full() + .max_h(px(400.)) + .flex_col() + .id("transfers-scroll-view") + .track_scroll(&scroll_handle) + .overflow_y_scroll() + .pr(px(14.)) + .child(list), + ) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(16.)) + .child( + Scrollbar::vertical(&scroll_handle) + .scrollbar_show(ScrollbarShow::Always), + ), + ), + ), + ) + } + }) }); } pub(crate) fn show_delete_confirm_dialog( @@ -792,9 +787,7 @@ impl Ashell { view.update(cx, |this, cx| { if let Some(handle) = this.active_sftp_handle() { let _ = handle.commands.send( - crate::sftp::SftpCommand::DeletePaths( - paths_to_delete.clone(), - ), + crate::sftp::SftpCommand::DeletePaths(paths_to_delete.clone()), ); } if let Some(sftp) = this.active_sftp_mut() { @@ -936,11 +929,32 @@ impl Ashell { } pub(crate) fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context) { let view = cx.entity(); + + // Unbind all workspace keys so they don't interfere with keybinding recording + crate::app::keybinding_recorder::unbind_all_workspace_keys(cx, &self.config); + self.keybinds_suspended = true; + window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("settings").to_string()) .w(px(840.)) .h(px(560.)) + .on_close({ + let view = view.clone(); + move |_, _window, cx| { + // Re-register all workspace keys when closing settings + view.update(cx, |this, cx| { + this.keybinds_suspended = false; + this.recording_action = None; + this.keybind_error = None; + crate::app::keybinding_recorder::bind_workspace_keys_from_config( + cx, + &this.config, + ); + cx.notify(); + }); + } + }) .content({ let view = view.clone(); move |content, _window, cx| { @@ -949,10 +963,76 @@ impl Ashell { let version = env!("CARGO_PKG_VERSION"); let view_clone_for_general = view.clone(); + let focus_handle = view.read(cx).focus_handle.clone(); + content.child( - Settings::new("settings") - .sidebar_width(px(180.)) - .sidebar_style(div().bg(cx.theme().background).style()) + div() + .size_full() + .track_focus(&focus_handle) + .on_key_down({ + let view = view.clone(); + move |ev: &gpui::KeyDownEvent, window, cx| { + view.update(cx, |this, cx| { + let Some(action) = this.recording_action.clone() else { + return; + }; + + window.prevent_default(); + cx.stop_propagation(); + + if ev.keystroke.key == "escape" { + this.recording_action = None; + cx.notify(); + return; + } + + let Some(new_key) = crate::app::keybinding_recorder::normalize_recorded_keystroke(ev) else { + return; + }; + + // Check for conflicts with other actions + if let Some((_conflict_id, conflict_label)) = + crate::app::keybinding_recorder::find_conflict( + &this.config, + &action, + &new_key, + ) + { + let formatted = crate::app::keybinding_recorder::format_keystroke(&new_key); + this.recording_action = None; + this.keybind_error = Some(( + action.clone(), + t!("keybind_conflict", key = formatted, action = conflict_label).to_string(), + )); + cx.notify(); + return; + } + + this.recording_action = None; + this.keybind_error = None; + this.config.set_key_binding(&action, &new_key); + if let Err(err) = this.config.save() { + tracing::error!("failed to save key binding: {err:#}"); + } + cx.notify(); + }); + } + }) + .on_mouse_down_out({ + let view = view.clone(); + move |_, _window, cx| { + view.update(cx, |this, cx| { + if this.recording_action.is_some() { + this.recording_action = None; + cx.notify(); + } + }); + } + }) + .child( + Settings::new("settings") + .sidebar_width(px(180.)) + .sidebar_style(div().bg(cx.theme().background).style()) .page( SettingPage::new(t!("settings_general").to_string()) .icon(IconName::Settings) @@ -1393,6 +1473,7 @@ impl Ashell { .page( SettingPage::new(t!("settings_key_bindings").to_string()) .icon(IconName::SquareTerminal) + .group(crate::app::keybinding_recorder::KeybindingsPage::render(&view, cx)) ) .page( SettingPage::new(t!("settings_help").to_string()) @@ -1432,6 +1513,7 @@ impl Ashell { })) ) ) + ) ) } }) diff --git a/src/app/keybinding_recorder.rs b/src/app/keybinding_recorder.rs new file mode 100644 index 0000000..758e162 --- /dev/null +++ b/src/app/keybinding_recorder.rs @@ -0,0 +1,334 @@ +use gpui::{ + Action as _, App, Entity, IntoElement, KeyBinding, KeyDownEvent, Keystroke, Unbind, prelude::*, +}; +use gpui_component::{ + Sizable, + button::{Button, ButtonVariants}, + kbd::Kbd, + setting::{SettingField, SettingGroup, SettingItem}, +}; +use rust_i18n::t; + +use crate::{Ashell, session::config::ConfigStore}; + +gpui::actions!( + ashell_workspace, + [ + OpenSettings, + OpenSession, + NewSsh, + ToggleSftpZoom, + FocusPaneLeft, + FocusPaneRight, + FocusPaneUp, + FocusPaneDown, + SplitPaneLeft, + SplitPaneRight, + SplitPaneUp, + SplitPaneDown, + ClosePane + ] +); + +pub struct KeybindingsPage; + +#[derive(Clone, Copy)] +pub(crate) struct WorkspaceAction { + id: &'static str, + label_key: &'static str, + default_suffix: &'static str, +} + +pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[ + WorkspaceAction { + id: "OpenSettings", + label_key: "settings_open_settings", + default_suffix: ",", + }, + WorkspaceAction { + id: "OpenSession", + label_key: "settings_open_session", + default_suffix: "o", + }, + WorkspaceAction { + id: "NewSsh", + label_key: "settings_new_ssh", + default_suffix: "n", + }, + WorkspaceAction { + id: "ToggleSftpZoom", + label_key: "settings_toggle_sftp_zoom", + default_suffix: "m", + }, + WorkspaceAction { + id: "FocusPaneLeft", + label_key: "settings_focus_pane_left", + default_suffix: "h", + }, + WorkspaceAction { + id: "FocusPaneRight", + label_key: "settings_focus_pane_right", + default_suffix: "l", + }, + WorkspaceAction { + id: "FocusPaneUp", + label_key: "settings_focus_pane_up", + default_suffix: "k", + }, + WorkspaceAction { + id: "FocusPaneDown", + label_key: "settings_focus_pane_down", + default_suffix: "j", + }, + WorkspaceAction { + id: "SplitPaneLeft", + label_key: "settings_split_pane_left", + default_suffix: "shift-h", + }, + WorkspaceAction { + id: "SplitPaneRight", + label_key: "settings_split_pane_right", + default_suffix: "shift-l", + }, + WorkspaceAction { + id: "SplitPaneUp", + label_key: "settings_split_pane_up", + default_suffix: "shift-k", + }, + WorkspaceAction { + id: "SplitPaneDown", + label_key: "settings_split_pane_down", + default_suffix: "shift-j", + }, + WorkspaceAction { + id: "ClosePane", + label_key: "settings_close_pane", + default_suffix: "w", + }, +]; + +pub(crate) fn default_modifier() -> &'static str { + if cfg!(target_os = "macos") { + "cmd" + } else { + "ctrl" + } +} + +pub(crate) fn default_keystroke(action_id: &str) -> Option { + WORKSPACE_ACTIONS + .iter() + .find(|action| action.id == action_id) + .map(|action| format!("{}-{}", default_modifier(), action.default_suffix)) +} + +pub(crate) fn configured_keystroke(config: &ConfigStore, action_id: &str) -> Option { + config + .key_bindings() + .get(action_id) + .cloned() + .or_else(|| default_keystroke(action_id)) +} + +pub(crate) fn normalize_recorded_keystroke(event: &KeyDownEvent) -> Option { + let key = event.keystroke.key.trim(); + if key.is_empty() { + return None; + } + + let mut parts = Vec::new(); + if event.keystroke.modifiers.control { + parts.push("ctrl".to_string()); + } + if event.keystroke.modifiers.alt { + parts.push("alt".to_string()); + } + if event.keystroke.modifiers.shift { + parts.push("shift".to_string()); + } + if event.keystroke.modifiers.platform { + parts.push("cmd".to_string()); + } + if event.keystroke.modifiers.function { + parts.push("fn".to_string()); + } + + parts.push(key.to_ascii_lowercase()); + let keystroke = parts.join("-"); + Keystroke::parse(&keystroke).ok().map(|_| keystroke) +} + +pub(crate) fn format_keystroke(keystroke: &str) -> String { + Keystroke::parse(keystroke) + .map(|stroke| Kbd::format(&stroke)) + .unwrap_or_else(|_| keystroke.to_string()) +} + +pub(crate) fn bind_workspace_keys_from_config(cx: &mut App, config: &ConfigStore) { + bind_workspace_actions(cx, config); +} + +/// Unbind all workspace keybindings (used when entering keybinding settings to prevent interference). +pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) { + let mut bindings = Vec::new(); + + macro_rules! unbind_action { + ($id:literal, $action:expr) => { + let default = default_keystroke($id).expect("workspace action has default key"); + let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone()); + let action_name = $action.name(); + + // Unbind both the default and configured keystroke + bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None)); + if configured != default { + bindings.push(KeyBinding::new( + &configured, + Unbind(action_name.into()), + None, + )); + } + }; + } + + unbind_action!("OpenSettings", crate::OpenSettings); + unbind_action!("OpenSession", crate::OpenSession); + unbind_action!("NewSsh", crate::NewSsh); + unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom); + unbind_action!("FocusPaneLeft", crate::FocusPaneLeft); + unbind_action!("FocusPaneRight", crate::FocusPaneRight); + unbind_action!("FocusPaneUp", crate::FocusPaneUp); + unbind_action!("FocusPaneDown", crate::FocusPaneDown); + unbind_action!("SplitPaneLeft", crate::SplitPaneLeft); + unbind_action!("SplitPaneRight", crate::SplitPaneRight); + unbind_action!("SplitPaneUp", crate::SplitPaneUp); + unbind_action!("SplitPaneDown", crate::SplitPaneDown); + unbind_action!("ClosePane", crate::ClosePane); + + cx.bind_keys(bindings); +} + +/// Check if a keystroke conflicts with any other action's binding. +/// Returns Some((conflicting_action_id, label)) if there is a conflict. +pub(crate) fn find_conflict( + config: &ConfigStore, + current_action_id: &str, + new_keystroke: &str, +) -> Option<(String, String)> { + for action in WORKSPACE_ACTIONS { + if action.id == current_action_id { + continue; + } + let existing = configured_keystroke(config, action.id).unwrap_or_default(); + if !existing.is_empty() && existing == new_keystroke { + return Some((action.id.to_string(), t!(action.label_key).to_string())); + } + } + None +} + +fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) { + let mut bindings = Vec::new(); + + macro_rules! bind_action { + ($id:literal, $action:expr) => { + let default = default_keystroke($id).expect("workspace action has default key"); + let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone()); + let action_name = $action.name(); + + if configured != default { + bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None)); + } + + bindings.push(KeyBinding::new(&configured, $action, None)); + }; + } + + bind_action!("OpenSettings", crate::OpenSettings); + bind_action!("OpenSession", crate::OpenSession); + bind_action!("NewSsh", crate::NewSsh); + bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom); + bind_action!("FocusPaneLeft", crate::FocusPaneLeft); + bind_action!("FocusPaneRight", crate::FocusPaneRight); + bind_action!("FocusPaneUp", crate::FocusPaneUp); + bind_action!("FocusPaneDown", crate::FocusPaneDown); + bind_action!("SplitPaneLeft", crate::SplitPaneLeft); + bind_action!("SplitPaneRight", crate::SplitPaneRight); + bind_action!("SplitPaneUp", crate::SplitPaneUp); + bind_action!("SplitPaneDown", crate::SplitPaneDown); + bind_action!("ClosePane", crate::ClosePane); + + cx.bind_keys(bindings); +} + +impl KeybindingsPage { + pub fn render(view: &Entity, cx: &mut App) -> SettingGroup { + let mut group = SettingGroup::new(); + + for action in WORKSPACE_ACTIONS { + let recording = view.read(cx).recording_action.as_deref() == Some(action.id); + let has_error = view + .read(cx) + .keybind_error + .as_ref() + .is_some_and(|(id, _)| id == action.id); + let error_msg = if has_error { + view.read(cx) + .keybind_error + .as_ref() + .map(|(_, msg)| msg.clone()) + } else { + None + }; + + let keystroke = { + let config = &view.read(cx).config; + configured_keystroke(config, action.id).unwrap_or_default() + }; + + let btn_label = if recording { + t!("press_new_key").to_string() + } else if keystroke.is_empty() { + t!("none").to_string() + } else { + format_keystroke(&keystroke) + }; + + let mut item = SettingItem::new( + t!(action.label_key).to_string(), + SettingField::render({ + let view = view.clone(); + let action_id = action.id.to_string(); + move |_, _window, _cx| { + Button::new(gpui::SharedString::from(format!("keybind-{action_id}"))) + .label(btn_label.clone()) + .small() + .when(recording, |this| this.primary()) + .when(has_error, |this| this.danger()) + .on_click({ + let view = view.clone(); + let action_id = action_id.clone(); + move |_event, window, cx| { + view.update(cx, |this, cx| { + // Clear any previous error when starting new recording + this.keybind_error = None; + this.recording_action = Some(action_id.clone()); + this.focus_handle.focus(window, cx); + cx.notify(); + }); + } + }) + .into_any_element() + } + }), + ); + + if let Some(msg) = error_msg { + item = item.description(msg); + } + + group = group.item(item); + } + + group + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 69b895a..1c68162 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,6 @@ pub mod constants; pub mod dialogs; +pub mod keybinding_recorder; pub mod startup; pub mod theme; pub mod ui; @@ -14,9 +15,8 @@ use std::{ }; use gpui::{ - AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, - SharedString, Size, UniformListScrollHandle, Window, point, - px, size, + AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, SharedString, Size, + UniformListScrollHandle, Window, point, px, size, }; use gpui_component::{ Theme, ThemeMode, ThemeRegistry, @@ -31,7 +31,6 @@ use crate::{ session::config::{AuthMethod, ConfigStore}, system::{SystemSampler, SystemSnapshot}, terminal::{self, BackendEvent, TabKind, TerminalTab}, - }; #[derive(Clone, Debug)] @@ -82,7 +81,10 @@ impl PaneLayout { pub fn replace_at(&mut self, path: &[usize], replacement: PaneLayout) { match (self, path) { (this @ PaneLayout::Single(_), []) => *this = replacement, - (PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _), [first, rest @ ..]) => { + ( + PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _), + [first, rest @ ..], + ) => { if let Some(child) = children.get_mut(*first) { child.replace_at(rest, replacement); } @@ -187,8 +189,6 @@ impl ScrollbarHandle for TerminalScrollbarHandle { } } - - pub(crate) struct Ashell { pub(crate) focus_handle: FocusHandle, pub(crate) selector_focus_handle: FocusHandle, @@ -247,6 +247,11 @@ pub(crate) struct Ashell { pub(crate) status: SharedString, pub(crate) config: ConfigStore, pub(crate) system_sampler: SystemSampler, + pub(crate) recording_action: Option, + /// Error message when a recorded keybinding conflicts with another + pub(crate) keybind_error: Option<(String, String)>, // (action_id, error_message) + /// Whether workspace keybindings are currently suspended (during settings) + pub(crate) keybinds_suspended: bool, pub(crate) system: SystemSnapshot, pub(crate) cpu_history: Vec, pub(crate) net_rx_history: Vec, @@ -256,7 +261,7 @@ pub(crate) struct Ashell { pub(crate) system_tab_id: Option, pub(crate) sftp_handles: std::collections::HashMap, - + pub(crate) remote_sample_in_flight: bool, pub(crate) runtime: Runtime, pub(crate) events_rx: mpsc::Receiver, @@ -307,7 +312,8 @@ impl Ashell { .placeholder("-----BEGIN OPENSSH PRIVATE KEY-----") }); let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/")); - let sftp_new_folder_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string())); + let sftp_new_folder_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string())); let _subscriptions = vec![ cx.subscribe_in(&host_input, window, Self::on_input_event), @@ -427,6 +433,9 @@ impl Ashell { status: "ready".into(), config, system_sampler, + recording_action: None, + keybind_error: None, + keybinds_suspended: false, system, cpu_history: Vec::with_capacity(20), net_rx_history: Vec::with_capacity(20), @@ -436,7 +445,7 @@ impl Ashell { system_tab_id: None, sftp_handles: std::collections::HashMap::new(), - + remote_sample_in_flight: false, runtime: Runtime::new().expect("create tokio runtime"), events_rx, @@ -478,7 +487,9 @@ impl Ashell { let base_path = self.sftp_path_input.read(cx).text().to_string(); let path = crate::sftp::join_remote(&base_path, &name); if let Some(handle) = self.active_sftp_handle() { - let _ = handle.commands.send(crate::sftp::SftpCommand::CreateDir(path)); + let _ = handle + .commands + .send(crate::sftp::SftpCommand::CreateDir(path)); } } self.sftp_creating_folder = false; @@ -545,7 +556,8 @@ impl Ashell { if progress.tab_id == tab_id { progress.lines.push(text.clone().into()); let _idx = progress.lines.len().saturating_sub(1); - self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0))); + self.connection_scroll_handle + .set_offset(point(px(0.), px(-99999.0))); } } self.status = text.into(); @@ -636,8 +648,8 @@ impl Ashell { if self.system_tab_id.as_deref() == Some(tab_id.as_str()) { self.system_status = Some(reason.clone().into()); } - let is_graceful_exit = reason == "local shell closed" - || reason == "ssh session closed"; + let is_graceful_exit = + reason == "local shell closed" || reason == "ssh session closed"; // Auto-close the pane on graceful exit (e.g. user typed exit) if is_graceful_exit { self.handle_tab_close(tab_id.clone()); @@ -650,7 +662,8 @@ impl Ashell { if progress.tab_id == tab_id { progress.lines.push(reason.clone().into()); let _idx = progress.lines.len().saturating_sub(1); - self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0))); + self.connection_scroll_handle + .set_offset(point(px(0.), px(-99999.0))); let _ = session_label; let _ = tab_title; progress.title = t!("connection_failed").into(); @@ -661,14 +674,16 @@ impl Ashell { progress.tab_id = tab_id.clone(); let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason); progress.lines.push(msg.into()); - self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0))); + self.connection_scroll_handle + .set_offset(point(px(0.), px(-99999.0))); progress.title = t!("connection_failed").into(); progress.failed = true; } else { // Already showing a failure dialog, just append the new failure let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason); progress.lines.push(msg.into()); - self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0))); + self.connection_scroll_handle + .set_offset(point(px(0.), px(-99999.0))); } } else if let Some(_) = session_label { needs_new_progress = true; @@ -748,7 +763,12 @@ impl Ashell { self.last_system_sample = Instant::now(); // Use system_tab_id (not active_tab) to decide remote vs local sampling if let Some(ref tab_id) = self.system_tab_id.clone() { - if self.tabs.iter().any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected) && self.system_status.is_none() { + if self + .tabs + .iter() + .any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected) + && self.system_status.is_none() + { self.request_active_system_snapshot(); return false; } @@ -782,12 +802,18 @@ impl Ashell { } pub(crate) fn request_active_system_snapshot(&mut self) { - let Some(ref tab_id) = self.system_tab_id.clone() else { return }; + let Some(ref tab_id) = self.system_tab_id.clone() else { + return; + }; let Some(backend) = (|| { let tab = self.tabs.iter().find(|t| t.id == *tab_id)?; - if !tab.connected { return None; } + if !tab.connected { + return None; + } Some(tab.backend.clone()) - })() else { return }; + })() else { + return; + }; if self.remote_sample_in_flight { return; } @@ -807,15 +833,10 @@ impl Ashell { let x = element_bounds.origin.x + px(cell_width) * cursor.col as f32 + px(cell_width) * range_utf16.start as f32; - let y = element_bounds.origin.y - + px(line_height) * cursor.row as f32; + let y = element_bounds.origin.y + px(line_height) * cursor.row as f32; Some(Bounds::new( point(x, y), - size( - px(cell_width), - px(line_height), - ), + size(px(cell_width), px(line_height)), )) } } - diff --git a/src/app/startup.rs b/src/app/startup.rs index 7ed3d43..69283e8 100644 --- a/src/app/startup.rs +++ b/src/app/startup.rs @@ -1,8 +1,13 @@ use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size}; use gpui_component::Root; -use crate::session::config::ConfigStore; use crate::Ashell; +use crate::session::config::ConfigStore; + +pub(crate) fn bind_workspace_keys(cx: &mut gpui::App) { + let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory()); + crate::app::keybinding_recorder::bind_workspace_keys_from_config(cx, &config); +} struct LocalMinutelyRoller { dir: std::path::PathBuf, @@ -13,9 +18,14 @@ struct LocalMinutelyRoller { impl LocalMinutelyRoller { fn new(dir: std::path::PathBuf, prefix: String) -> Self { - Self { dir, prefix, current_minute: 60, file: None } + Self { + dir, + prefix, + current_minute: 60, + file: None, + } } - + fn rollover(&mut self, now: chrono::DateTime) -> std::io::Result<()> { use chrono::Timelike; let minute = now.minute(); @@ -28,14 +38,18 @@ impl LocalMinutelyRoller { .open(&path)?; self.file = Some(file); self.current_minute = minute; - + // Cleanup old files keeping last 6 if let Ok(entries) = std::fs::read_dir(&self.dir) { let mut files: Vec<_> = entries .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix)) .collect(); - files.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH)); + files.sort_by_key(|e| { + e.metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); if files.len() > 6 { for file in files.iter().take(files.len() - 6) { let _ = std::fs::remove_file(file.path()); @@ -57,7 +71,7 @@ impl std::io::Write for LocalMinutelyRoller { Ok(buf.len()) } } - + fn flush(&mut self) -> std::io::Result<()> { if let Some(f) = &mut self.file { f.flush() @@ -73,11 +87,11 @@ pub(crate) fn init_logging() { let log_dir = directories::BaseDirs::new() .map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log")) .unwrap_or_else(|| std::path::PathBuf::from(".")); - + std::fs::create_dir_all(&log_dir).ok(); let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string()); - + let (non_blocking, _guard) = tracing_appender::non_blocking(roller); // Leak the guard so it lives for the entire duration of the app since GPUI's run might not return std::mem::forget(_guard); @@ -86,11 +100,15 @@ pub(crate) fn init_logging() { .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); let stdout_layer = if cfg!(debug_assertions) { - Some(tracing_subscriber::fmt::layer().with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()).with_target(true)) + Some( + tracing_subscriber::fmt::layer() + .with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()) + .with_target(true), + ) } else { None }; - + let file_layer = tracing_subscriber::fmt::layer() .with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()) .with_writer(non_blocking) @@ -107,7 +125,10 @@ pub(crate) fn init_logging() { #[cfg(target_os = "macos")] pub(crate) fn sync_macos_launch_environment() { let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - let Ok(output) = std::process::Command::new(&shell).args(["-l", "-c", "env -0"]).output() else { + let Ok(output) = std::process::Command::new(&shell) + .args(["-l", "-c", "env -0"]) + .output() + else { return; }; if !output.status.success() { @@ -218,7 +239,7 @@ pub(crate) fn open_main_window(cx: &mut App) { let view = cx.new(|cx| Ashell::new(window, cx)); tracing::info!("[ui] main application window opened"); - + let workspace_panels_clone = view.read(cx).workspace_panels.clone(); let body_panels_clone = view.read(cx).body_panels.clone(); let view_clone = view.clone(); @@ -231,24 +252,30 @@ pub(crate) fn open_main_window(cx: &mut App) { let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory()); let current_bounds = window.window_bounds(); let saved_bounds = match current_bounds { - gpui::WindowBounds::Fullscreen(b) => crate::session::config::SavedWindowBounds::Fullscreen { - x: b.origin.x.into(), - y: b.origin.y.into(), - width: b.size.width.into(), - height: b.size.height.into(), - }, - gpui::WindowBounds::Maximized(b) => crate::session::config::SavedWindowBounds::Maximized { - x: b.origin.x.into(), - y: b.origin.y.into(), - width: b.size.width.into(), - height: b.size.height.into(), - }, - gpui::WindowBounds::Windowed(b) => crate::session::config::SavedWindowBounds::Windowed { - x: b.origin.x.into(), - y: b.origin.y.into(), - width: b.size.width.into(), - height: b.size.height.into(), - }, + gpui::WindowBounds::Fullscreen(b) => { + crate::session::config::SavedWindowBounds::Fullscreen { + x: b.origin.x.into(), + y: b.origin.y.into(), + width: b.size.width.into(), + height: b.size.height.into(), + } + } + gpui::WindowBounds::Maximized(b) => { + crate::session::config::SavedWindowBounds::Maximized { + x: b.origin.x.into(), + y: b.origin.y.into(), + width: b.size.width.into(), + height: b.size.height.into(), + } + } + gpui::WindowBounds::Windowed(b) => { + crate::session::config::SavedWindowBounds::Windowed { + x: b.origin.x.into(), + y: b.origin.y.into(), + width: b.size.width.into(), + height: b.size.height.into(), + } + } }; let workspace_sizes: Vec = workspace_panels_clone .read(cx) @@ -271,4 +298,3 @@ pub(crate) fn open_main_window(cx: &mut App) { }) .expect("failed to open window"); } - diff --git a/src/app/theme.rs b/src/app/theme.rs index 873d4e3..651c562 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -1,8 +1,6 @@ use anyhow::{Context as _, Result}; use gpui::{App, Context, SharedString, Window, px}; -use gpui_component::{ - ActiveTheme as _, Theme, ThemeMode, ThemeRegistry, -}; +use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry}; use crate::Ashell; @@ -14,9 +12,12 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[ ]; pub(crate) fn load_fonts(cx: &mut App) -> Result<()> { - let regular = - std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice()); - let bold = std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice()); + let regular = std::borrow::Cow::Borrowed( + include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(), + ); + let bold = std::borrow::Cow::Borrowed( + include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(), + ); cx.text_system() .add_fonts(vec![regular, bold]) .context("load Maple Mono NF CN fonts")?; @@ -39,7 +40,12 @@ pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) { } impl Ashell { - pub(crate) fn switch_theme_mode(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context) { + pub(crate) fn switch_theme_mode( + &mut self, + mode: ThemeMode, + window: &mut Window, + cx: &mut Context, + ) { self.follow_system_theme = false; self.theme_mode = mode; self.apply_theme_preferences(window, cx); @@ -48,7 +54,12 @@ impl Ashell { cx.notify(); } - pub(crate) fn apply_theme(&mut self, name: SharedString, window: &mut Window, cx: &mut Context) { + pub(crate) fn apply_theme( + &mut self, + name: SharedString, + window: &mut Window, + cx: &mut Context, + ) { let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else { self.status = format!("theme not found: {name}").into(); cx.notify(); @@ -84,7 +95,12 @@ impl Ashell { cx.notify(); } - pub(crate) fn set_display_language(&mut self, locale: &str, window: &mut Window, cx: &mut Context) { + pub(crate) fn set_display_language( + &mut self, + locale: &str, + window: &mut Window, + cx: &mut Context, + ) { self.config.set_locale(locale); let mut active_locale = locale.to_string(); if active_locale == "system" { @@ -143,6 +159,4 @@ impl Ashell { tracing::warn!("failed to save theme preferences: {err:#}"); } } - - } diff --git a/src/app/ui.rs b/src/app/ui.rs index 0c326e9..fb8493d 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -1,10 +1,8 @@ - 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, point, prelude::FluentBuilder as _, px, rems, uniform_list, + Context, ElementId, Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement, + MouseButton, MouseDownEvent, ParentElement as _, PathBuilder, Pixels, Render, + StatefulInteractiveElement as _, Styled as _, Window, canvas, div, point, + prelude::FluentBuilder as _, px, rems, uniform_list, }; use gpui_component::{ ActiveTheme, Disableable as _, ElementExt, IconName, Root, Sizable as _, @@ -24,8 +22,8 @@ use rust_i18n::t; use crate::{ Ashell, PaneLayout, app::constants::{SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT}, - sftp::ops::is_editable_text_file, sftp::format_mtime, + sftp::ops::is_editable_text_file, system::format_bytes, terminal::{self, TabKind, TerminalTab}, }; @@ -70,6 +68,49 @@ impl Ashell { ) } + pub(crate) fn toggle_sftp_minimized(&mut self, window: &mut Window, cx: &mut Context) { + let state = self.body_panels.clone(); + let minimized = self.sftp_panel_minimized; + + if !minimized { + let sizes = state.read(cx).sizes(); + if sizes.len() > 1 { + self.prev_monitoring_size = Some(sizes[1]); + } + self.sftp_panel_minimized = true; + } else { + self.sftp_panel_minimized = false; + let prev_size = self.prev_monitoring_size.unwrap_or(px(328.)); + + cx.on_next_frame( + window, + move |_this: &mut crate::app::Ashell, + window: &mut gpui::Window, + cx: &mut gpui::Context| { + cx.on_next_frame( + window, + move |this: &mut crate::app::Ashell, + window: &mut gpui::Window, + cx: &mut gpui::Context| { + this.body_panels.update(cx, |state, cx| { + let sizes = state.sizes(); + let c_size_f32: f32 = sizes.iter().map(|s| s.as_f32()).sum(); + let c_size = px(c_size_f32); + + if c_size > px(0.0) && prev_size < c_size { + let target_p0 = c_size - prev_size; + state.resize_panel(0, target_p0, window, cx); + } + }); + cx.notify(); + }, + ); + }, + ); + } + cx.notify(); + } + fn render_sftp_panel( &mut self, _window: &mut Window, @@ -81,7 +122,7 @@ impl Ashell { .flex_none() .h(px(34.)) .items_center() - .gap_2() + .gap_2() .border_b_1() .border_color(cx.theme().border) .bg(cx.theme().tab_bar) @@ -200,37 +241,13 @@ impl Ashell { Button::new("sftp-minimize-toggle") .ghost() .small() - .icon(if self.sftp_panel_minimized { IconName::ChevronUp } else { IconName::ChevronDown }) + .icon(if self.sftp_panel_minimized { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) .on_click(cx.listener(|this, _, window, cx| { - let state = this.body_panels.clone(); - let minimized = this.sftp_panel_minimized; - - if !minimized { - // Going to minimized: save the current size - let sizes = state.read(cx).sizes(); - if sizes.len() > 1 { - this.prev_monitoring_size = Some(sizes[1]); - } - this.sftp_panel_minimized = true; - } else { - // Going to unminimized: restore the old size - this.sftp_panel_minimized = false; - let prev_size = this.prev_monitoring_size.unwrap_or(px(328.)); - - cx.on_next_frame(window, move |_this: &mut crate::app::Ashell, window: &mut gpui::Window, cx: &mut gpui::Context| { - cx.on_next_frame(window, move |this: &mut crate::app::Ashell, window: &mut gpui::Window, cx: &mut gpui::Context| { - this.body_panels.update(cx, |state, cx| { - let sizes = state.sizes(); - let c_size_f32: f32 = sizes.iter().map(|s| s.as_f32()).sum(); - let c_size = px(c_size_f32); - let target_p0 = c_size - prev_size; - state.resize_panel(0, target_p0, window, cx); - }); - cx.notify(); - }); - }); - } - cx.notify(); + this.toggle_sftp_minimized(window, cx); })), ); @@ -239,15 +256,15 @@ impl Ashell { .gap_0() .border_color(cx.theme().border) .bg(cx.theme().background); - + if !self.sftp_panel_minimized { panel = panel.size_full(); } else { panel = panel.flex_none(); } - + panel = panel.child(header); - + if !self.sftp_panel_minimized { panel = panel.child( v_flex() @@ -311,122 +328,122 @@ impl Ashell { if !self.sftp_panel_minimized { panel = panel - .child( - h_flex() - .h(px(36.)) - .items_center() - .gap_2() - .px_3() - .border_b_1() - .border_color(cx.theme().border) - .bg(cx.theme().muted) - .child( - Button::new("sftp-up") - .ghost() - .small() - .icon(IconName::ChevronUp) - .on_click(cx.listener(move |this, _, _, cx| { - this.navigate_sftp(parent_path.clone(), cx); - })), - ) - .child(Input::new(&self.sftp_path_input).flex_1().tab_index(0)) - .child(div().flex_none()), - ) - .child( - h_flex() - .h(px(26.)) - .px_3() - .items_center() - .gap_2() - .border_b_1() - .border_color(cx.theme().border) - .bg(cx.theme().muted.opacity(0.8)) - .child( - h_flex() - .w(px(24.)) - .flex_none() - .items_center() - .justify_center() - .child( - Checkbox::new("sftp-select-all") - .checked(all_selected) - .on_click(cx.listener(move |this, checked, _, cx| { - this.toggle_all_sftp_entries(*checked, cx); - })), - ), - ) - .child( - h_flex() - .flex_1() - .min_w(px(0.)) - .items_center() - .gap_2() - .child(div().w(icon_col_width).flex_none()) - .child( - div() - .flex_1() - .text_size(rems(0.917)) - .text_color(cx.theme().muted_foreground) - .child(t!("name")), - ), - ) - .child( - div() - .w(size_col_width) - .flex_none() - .text_size(rems(0.917)) - .text_color(cx.theme().muted_foreground) - .child(t!("size")), - ) - .child( - div() - .w(modified_col_width) - .flex_none() - .text_size(rems(0.917)) - .text_color(cx.theme().muted_foreground) - .child(t!("modified")), - ), - ) - .child( - div() - .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 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( + .child( + h_flex() + .h(px(36.)) + .items_center() + .gap_2() + .px_3() + .border_b_1() + .border_color(cx.theme().border) + .bg(cx.theme().muted) + .child( + Button::new("sftp-up") + .ghost() + .small() + .icon(IconName::ChevronUp) + .on_click(cx.listener(move |this, _, _, cx| { + this.navigate_sftp(parent_path.clone(), cx); + })), + ) + .child(Input::new(&self.sftp_path_input).flex_1().tab_index(0)) + .child(div().flex_none()), + ) + .child( + h_flex() + .h(px(26.)) + .px_3() + .items_center() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .bg(cx.theme().muted.opacity(0.8)) + .child( + h_flex() + .w(px(24.)) + .flex_none() + .items_center() + .justify_center() + .child( + Checkbox::new("sftp-select-all") + .checked(all_selected) + .on_click(cx.listener(move |this, checked, _, cx| { + this.toggle_all_sftp_entries(*checked, cx); + })), + ), + ) + .child( + h_flex() + .flex_1() + .min_w(px(0.)) + .items_center() + .gap_2() + .child(div().w(icon_col_width).flex_none()) + .child( + div() + .flex_1() + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(t!("name")), + ), + ) + .child( + div() + .w(size_col_width) + .flex_none() + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(t!("size")), + ) + .child( + div() + .w(modified_col_width) + .flex_none() + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(t!("modified")), + ), + ) + .child( + div() + .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 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( h_flex() .w_full() .h(px(28.)) @@ -555,44 +572,44 @@ impl Ashell { .child(div().w(px(12.)).flex_none()) .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( - h_flex() - .flex_none() - .h(px(24.)) - .px_3() - .items_center() - .border_t_1() - .border_color(cx.theme().border) - .bg(cx.theme().tab_bar) - .child( - div() - .min_w(px(0.)) - .overflow_hidden() - .text_size(rems(0.917)) - .text_color(cx.theme().muted_foreground) - .child(status), - ), - ); + }) + .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( + h_flex() + .flex_none() + .h(px(24.)) + .px_3() + .items_center() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().tab_bar) + .child( + div() + .min_w(px(0.)) + .overflow_hidden() + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(status), + ), + ); } panel.into_any_element() @@ -988,7 +1005,7 @@ impl Ashell { .text_color(muted_fg) .child(format!("{:.0}%", pct)), ) - })) + })), ) .child( div() @@ -999,10 +1016,10 @@ impl Ashell { .w(px(8.)) .child( Scrollbar::vertical(&self.disk_scroll_handle) - .scrollbar_show(ScrollbarShow::Scrolling) - ) + .scrollbar_show(ScrollbarShow::Scrolling), + ), ) - .into_any_element() + .into_any_element(), ) .into_any_element(), ) @@ -1053,10 +1070,26 @@ impl Ashell { .child( h_flex() .justify_between() - .child(div().text_size(rems(0.85)).text_color(cpu_color).child(t!("cpu").to_string())) - .child(div().text_size(rems(0.85)).text_color(muted_fg).child(format!("{:.1}%", cpu_pct * 100.0))), + .child( + div() + .text_size(rems(0.85)) + .text_color(cpu_color) + .child(t!("cpu").to_string()), + ) + .child( + div() + .text_size(rems(0.85)) + .text_color(muted_fg) + .child(format!("{:.1}%", cpu_pct * 100.0)), + ), ) - .child(Progress::new("sidebar-cpu").value(cpu_pct * 100.0).color(cpu_color).with_size(px(4.)).w_full()) + .child( + Progress::new("sidebar-cpu") + .value(cpu_pct * 100.0) + .color(cpu_color) + .with_size(px(4.)) + .w_full(), + ), ) .child( v_flex() @@ -1064,10 +1097,26 @@ impl Ashell { .child( h_flex() .justify_between() - .child(div().text_size(rems(0.85)).text_color(mem_color).child(t!("mem").to_string())) - .child(div().text_size(rems(0.85)).text_color(muted_fg).child(self.system.mem_detail.clone())), + .child( + div() + .text_size(rems(0.85)) + .text_color(mem_color) + .child(t!("mem").to_string()), + ) + .child( + div() + .text_size(rems(0.85)) + .text_color(muted_fg) + .child(self.system.mem_detail.clone()), + ), ) - .child(Progress::new("sidebar-mem").value(mem_pct * 100.0).color(mem_color).with_size(px(4.)).w_full()) + .child( + Progress::new("sidebar-mem") + .value(mem_pct * 100.0) + .color(mem_color) + .with_size(px(4.)) + .w_full(), + ), ) .child( v_flex() @@ -1075,10 +1124,26 @@ impl Ashell { .child( h_flex() .justify_between() - .child(div().text_size(rems(0.85)).text_color(swap_color).child(t!("swap").to_string())) - .child(div().text_size(rems(0.85)).text_color(muted_fg).child(self.system.swap_detail.clone())), + .child( + div() + .text_size(rems(0.85)) + .text_color(swap_color) + .child(t!("swap").to_string()), + ) + .child( + div() + .text_size(rems(0.85)) + .text_color(muted_fg) + .child(self.system.swap_detail.clone()), + ), ) - .child(Progress::new("sidebar-swap").value(swap_pct * 100.0).color(swap_color).with_size(px(4.)).w_full()) + .child( + Progress::new("sidebar-swap") + .value(swap_pct * 100.0) + .color(swap_color) + .with_size(px(4.)) + .w_full(), + ), ) .child( v_flex() @@ -1087,12 +1152,22 @@ impl Ashell { h_flex() .justify_between() .items_center() - .child(div().text_size(rems(0.85)).text_color(disk_color).child(t!("disk").to_string())) + .child( + div() + .text_size(rems(0.85)) + .text_color(disk_color) + .child(t!("disk").to_string()), + ) .children(if self.system.disks.len() > 3 { - Some(div().text_size(rems(0.65)).text_color(muted_fg).child(t!("scroll").to_string())) + Some( + div() + .text_size(rems(0.65)) + .text_color(muted_fg) + .child(t!("scroll").to_string()), + ) } else { None - }) + }), ) .child( div() @@ -1107,7 +1182,9 @@ impl Ashell { .gap_2() .children(self.system.disks.iter().map(|disk| { let pct = if disk.total_bytes > 0 { - (disk.total_bytes - disk.available_bytes) as f64 / disk.total_bytes as f64 * 100.0 + (disk.total_bytes - disk.available_bytes) as f64 + / disk.total_bytes as f64 + * 100.0 } else { 0.0 }; @@ -1118,11 +1195,27 @@ impl Ashell { .child( h_flex() .justify_between() - .child(div().text_size(rems(0.75)).text_color(muted_fg).child(mount_short)) - .child(div().text_size(rems(0.75)).text_color(muted_fg).child(format!("{:.1}%", pct))), + .child( + div() + .text_size(rems(0.75)) + .text_color(muted_fg) + .child(mount_short), + ) + .child( + div() + .text_size(rems(0.75)) + .text_color(muted_fg) + .child(format!("{:.1}%", pct)), + ), ) - .child(Progress::new(mount_id).value(pct as f32).color(disk_color).with_size(px(4.)).w_full()) - })) + .child( + Progress::new(mount_id) + .value(pct as f32) + .color(disk_color) + .with_size(px(4.)) + .w_full(), + ) + })), ) .child( div() @@ -1133,10 +1226,10 @@ impl Ashell { .w(px(8.)) .child( Scrollbar::vertical(&self.disk_scroll_handle) - .scrollbar_show(ScrollbarShow::Scrolling) - ) - ) - ) + .scrollbar_show(ScrollbarShow::Scrolling), + ), + ), + ), ) .child( v_flex() @@ -1144,8 +1237,18 @@ impl Ashell { .child( h_flex() .justify_between() - .child(div().text_size(rems(0.85)).text_color(net_color).child(t!("net").to_string())) - .child(div().text_size(rems(0.85)).text_color(muted_fg).child(t!("live"))), + .child( + div() + .text_size(rems(0.85)) + .text_color(net_color) + .child(t!("net").to_string()), + ) + .child( + div() + .text_size(rems(0.85)) + .text_color(muted_fg) + .child(t!("live")), + ), ) .child( h_flex() @@ -1155,18 +1258,38 @@ impl Ashell { .flex_1() .min_w(px(0.)) .gap_1() - .child(div().flex_none().text_size(rems(0.75)).text_color(net_color).child("↓")) - .child(div().text_size(rems(0.75)).child(self.system.net_rx.clone())) + .child( + div() + .flex_none() + .text_size(rems(0.75)) + .text_color(net_color) + .child("↓"), + ) + .child( + div() + .text_size(rems(0.75)) + .child(self.system.net_rx.clone()), + ), ) .child( h_flex() .flex_1() .min_w(px(0.)) .gap_1() - .child(div().flex_none().text_size(rems(0.75)).text_color(cx.theme().chart_5).child("↑")) - .child(div().text_size(rems(0.75)).child(self.system.net_tx.clone())) - ) - ) + .child( + div() + .flex_none() + .text_size(rems(0.75)) + .text_color(cx.theme().chart_5) + .child("↑"), + ) + .child( + div() + .text_size(rems(0.75)) + .child(self.system.net_tx.clone()), + ), + ), + ), ) } @@ -1207,8 +1330,7 @@ impl Ashell { .on_click(cx.listener(|this, _, window, cx| { this.show_settings_dialog(window, cx) })), - ) - + ), ) .child( div() @@ -1311,45 +1433,48 @@ impl Ashell { let clone_value = edit_id.clone(); let delete_value = delete_id.clone(); menu.item( - PopupMenuItem::new(t!("clone").to_string()).on_click( - window.listener_for( - &view, - move |this, _, window, cx| { - this.clone_saved_session( - clone_value.clone(), - window, - cx, - ) - }, - ), - ), + PopupMenuItem::new( + t!("clone").to_string(), + ) + .on_click(window.listener_for( + &view, + move |this, _, window, cx| { + this.clone_saved_session( + clone_value.clone(), + window, + cx, + ) + }, + )), ) .item( - PopupMenuItem::new(t!("edit").to_string()).on_click( - window.listener_for( - &view, - move |this, _, window, cx| { - this.edit_saved_session( - edit_value.clone(), - window, - cx, - ) - }, - ), - ), + PopupMenuItem::new( + t!("edit").to_string(), + ) + .on_click(window.listener_for( + &view, + move |this, _, window, cx| { + this.edit_saved_session( + edit_value.clone(), + window, + cx, + ) + }, + )), ) .item( - PopupMenuItem::new(t!("delete").to_string()).on_click( - window.listener_for( - &view, - move |this, _, _, cx| { - this.remove_saved_session( - delete_value.clone(), - cx, - ) - }, - ), - ), + PopupMenuItem::new( + t!("delete").to_string(), + ) + .on_click(window.listener_for( + &view, + move |this, _, _, cx| { + this.remove_saved_session( + delete_value.clone(), + cx, + ) + }, + )), ) } }) @@ -1410,8 +1535,12 @@ impl Ashell { .tab_groups .iter() .map(|g| { - let pane_ids: Vec = - g.pane_root.tab_ids().iter().map(|s| s.to_string()).collect(); + let pane_ids: Vec = g + .pane_root + .tab_ids() + .iter() + .map(|s| s.to_string()) + .collect(); (g.id.clone(), g.title.clone(), pane_ids) }) .collect(); @@ -1444,8 +1573,12 @@ impl Ashell { } else { title.clone() }; - let close_id = if self.active_group.as_ref() == Some(&gid) { - self.active_tab.clone().unwrap_or_else(|| pane_ids.first().cloned().unwrap_or_default()) + let close_id = if self.active_group.as_ref() + == Some(&gid) + { + self.active_tab.clone().unwrap_or_else(|| { + pane_ids.first().cloned().unwrap_or_default() + }) } else { pane_ids.first().cloned().unwrap_or_default() }; @@ -1464,12 +1597,7 @@ impl Ashell { .unwrap_or(cx.theme().success); Tab::new() .min_w(px(80.)) - .prefix( - div() - .w(px(5.)) - .h(px(32.)) - .bg(dot_color), - ) + .prefix(div().w(px(5.)).h(px(32.)).bg(dot_color)) .child( div() .when(ix == selected, |this| { @@ -1481,11 +1609,7 @@ impl Ashell { ) .on_click(cx.listener( move |this, _, window, cx| { - this.activate_group( - gid.clone(), - window, - cx, - ) + this.activate_group(gid.clone(), window, cx) }, )) .suffix( @@ -1537,38 +1661,35 @@ impl Ashell { this.show_selector_dialog(window, cx) })), ) - .child( - Button::new("split-horizontal") - .secondary() - .small() - .rounded(px(999.)) - .icon(IconName::PanelBottom) - .on_click(cx.listener(|this, _, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - this.split_current_pane("down", cx); - })) - ) - .child( - Button::new("split-vertical") - .secondary() - .small() - .rounded(px(999.)) - .icon(IconName::PanelRight) - .on_click(cx.listener(|this, _, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - this.split_current_pane("right", cx); - })) - ), + .child( + Button::new("split-horizontal") + .secondary() + .small() + .rounded(px(999.)) + .icon(IconName::PanelBottom) + .on_click(cx.listener(|this, _, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + this.split_current_pane("down", cx); + })), + ) + .child( + Button::new("split-vertical") + .secondary() + .small() + .rounded(px(999.)) + .icon(IconName::PanelRight) + .on_click(cx.listener(|this, _, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + this.split_current_pane("right", cx); + })), + ), ), ) } - fn render_terminal_panel( - &mut self, - cx: &mut Context, - ) -> impl IntoElement { + fn render_terminal_panel(&mut self, cx: &mut Context) -> impl IntoElement { let has_active = self.active_tab.is_some(); let pane_tree = self.pane_root.clone(); @@ -1638,10 +1759,13 @@ impl Ashell { this.terminal_bounds.insert(tab_id_clone.clone(), bounds); }); }) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _, cx| { - this.focus_pane_with_id(tab_id_clone2.clone()); - cx.notify(); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.focus_pane_with_id(tab_id_clone2.clone()); + cx.notify(); + }), + ) .child(terminal::element::TerminalElement::new( cx.entity(), focus_handle, @@ -1677,17 +1801,46 @@ impl Ashell { el = div() .size_full() .relative() - .child(div().absolute().top(px(1.)).left(px(1.)).right(px(1.)).h(px(1.)).bg(indicator_color)) - .child(div().absolute().bottom(px(1.)).left(px(1.)).right(px(1.)).h(px(1.)).bg(indicator_color)) - .child(div().absolute().left(px(1.)).top(px(1.)).bottom(px(1.)).w(px(1.)).bg(indicator_color)) - .child(div().absolute().right(px(1.)).top(px(1.)).bottom(px(1.)).w(px(1.)).bg(indicator_color)) + .child( + div() + .absolute() + .top(px(1.)) + .left(px(1.)) + .right(px(1.)) + .h(px(1.)) + .bg(indicator_color), + ) + .child( + div() + .absolute() + .bottom(px(1.)) + .left(px(1.)) + .right(px(1.)) + .h(px(1.)) + .bg(indicator_color), + ) + .child( + div() + .absolute() + .left(px(1.)) + .top(px(1.)) + .bottom(px(1.)) + .w(px(1.)) + .bg(indicator_color), + ) + .child( + div() + .absolute() + .right(px(1.)) + .top(px(1.)) + .bottom(px(1.)) + .w(px(1.)) + .bg(indicator_color), + ) .p(px(4.)) .child(el); } else { - el = div() - .size_full() - .p(px(4.)) - .child(el); + el = div().size_full().p(px(4.)).child(el); } } @@ -1709,12 +1862,21 @@ impl Ashell { .cursor_row_resize() .bg(cx.theme().border) .hover(|s| s.bg(cx.theme().accent)) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - this.start_drag_split(splitter_path.clone(), i, event, window, cx); - })) - .into_any_element() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + this.start_drag_split( + splitter_path.clone(), + i, + event, + window, + cx, + ); + }), + ) + .into_any_element(), ); } let mut child_path = path.to_vec(); @@ -1729,55 +1891,62 @@ impl Ashell { .min_h(px(0.)) .overflow_hidden() .child(Self::render_pane_tree(this, child, &child_path, cx)) - .into_any_element() + .into_any_element(), ); items })) .into_any_element() } - PaneLayout::Vertical(children, ratio) => { - h_flex() - .items_stretch() - .size_full() - .children(children.iter().enumerate().flat_map(|(i, child)| { - let mut items: Vec = Vec::new(); - if i > 0 { - let mut splitter_path = path.to_vec(); - splitter_path.push(i - 1); - items.push( - div() - .w(px(4.)) - .h_full() - .flex_none() - .cursor_col_resize() - .bg(cx.theme().border) - .hover(|s| s.bg(cx.theme().accent)) - .on_mouse_down(MouseButton::Left, cx.listener(move |this, event, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - this.start_drag_split(splitter_path.clone(), i, event, window, cx); - })) - .into_any_element() - ); - } - let mut child_path = path.to_vec(); - child_path.push(i); + PaneLayout::Vertical(children, ratio) => h_flex() + .items_stretch() + .size_full() + .children(children.iter().enumerate().flat_map(|(i, child)| { + let mut items: Vec = Vec::new(); + if i > 0 { + let mut splitter_path = path.to_vec(); + splitter_path.push(i - 1); items.push( div() - .flex_grow(if children.len() == 2 { - if i == 0 { *ratio } else { 1.0 - *ratio } - } else { - 1.0 - }) - .min_w(px(0.)) - .overflow_hidden() - .child(Self::render_pane_tree(this, child, &child_path, cx)) - .into_any_element() + .w(px(4.)) + .h_full() + .flex_none() + .cursor_col_resize() + .bg(cx.theme().border) + .hover(|s| s.bg(cx.theme().accent)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + this.start_drag_split( + splitter_path.clone(), + i, + event, + window, + cx, + ); + }), + ) + .into_any_element(), ); - items - })) - .into_any_element() - } + } + let mut child_path = path.to_vec(); + child_path.push(i); + items.push( + div() + .flex_grow(if children.len() == 2 { + if i == 0 { *ratio } else { 1.0 - *ratio } + } else { + 1.0 + }) + .min_w(px(0.)) + .overflow_hidden() + .child(Self::render_pane_tree(this, child, &child_path, cx)) + .into_any_element(), + ); + items + })) + .into_any_element(), } } } @@ -1816,8 +1985,7 @@ impl Render for Ashell { } if let Some(snapshot) = self.active_snapshot().as_ref() { if let Some(scrollbar) = self.terminal_scrollbars.get(&active_id) { - scrollbar - .update(snapshot, px(self.terminal_line_height())); + scrollbar.update(snapshot, px(self.terminal_line_height())); } } } @@ -1851,8 +2019,8 @@ impl Render for Ashell { div().flex_1().min_h(px(0.)).child( v_resizable("ashell-body") .with_state(&self.body_panels) - .child(resizable_panel().child(self.render_terminal_panel(cx))) - ) + .child(resizable_panel().child(self.render_terminal_panel(cx))), + ), ) .child( div() @@ -1861,7 +2029,7 @@ impl Render for Ashell { .w_full() .border_t_1() .border_color(cx.theme().border) - .child(monitoring_contents) + .child(monitoring_contents), ) .into_any_element() } else { @@ -1876,7 +2044,7 @@ impl Render for Ashell { .and_then(|s| s.get(1).copied()) .unwrap_or(default_panel_height))) .size_range(px(min_panel_height)..px(1200.)) - .child(monitoring_contents) + .child(monitoring_contents), ) .into_any_element() }; @@ -1896,10 +2064,30 @@ impl Render for Ashell { .child(main_area); div() + .id("ashell-root") .size_full() .bg(cx.theme().background) .text_color(cx.theme().foreground) .font_family(self.ui_font_family.clone()) + .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::ToggleSftpZoom, window, cx| { + this.toggle_sftp_minimized(window, cx); + })) + .on_action(cx.listener(|this, _: &crate::FocusPaneLeft, _, _| this.focus_adjacent_pane("left"))) + .on_action(cx.listener(|this, _: &crate::FocusPaneRight, _, _| this.focus_adjacent_pane("right"))) + .on_action(cx.listener(|this, _: &crate::FocusPaneUp, _, _| this.focus_adjacent_pane("up"))) + .on_action(cx.listener(|this, _: &crate::FocusPaneDown, _, _| this.focus_adjacent_pane("down"))) + .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))) + .on_action(cx.listener(|this, _: &crate::SplitPaneDown, _, cx| this.split_current_pane("down", cx))) + .on_action(cx.listener(|this, _: &crate::ClosePane, _, cx| { + if let Some(active_id) = this.active_tab.clone() { + this.close_tab(active_id, cx); + } + })) .child(workspace) .children(Root::render_dialog_layer(window, cx)) .children(Root::render_sheet_layer(window, cx)) diff --git a/src/backend/ssh.rs b/src/backend/ssh.rs index 338ec4a..8b4cb4d 100644 --- a/src/backend/ssh.rs +++ b/src/backend/ssh.rs @@ -94,7 +94,9 @@ async fn run_ssh( ), }); - let handle = Arc::new(tokio::sync::Mutex::new(connect_and_authenticate(&tab_id, &session, &events).await?)); + let handle = Arc::new(tokio::sync::Mutex::new( + connect_and_authenticate(&tab_id, &session, &events).await?, + )); let mut channel = handle .lock() @@ -223,7 +225,11 @@ async fn connect_and_authenticate( ..Default::default() }); let addr = format!("{}:{}", session.host, session.port); - tracing::info!("[ssh] initiating tcp connection to {} (user: {})", addr, session.user); + tracing::info!( + "[ssh] initiating tcp connection to {} (user: {})", + addr, + session.user + ); let _ = events.send(BackendEvent::Status { tab_id: tab_id.to_string(), text: format!("opening tcp connection to {addr}"), @@ -231,12 +237,16 @@ async fn connect_and_authenticate( let mut handle = client::connect(config, addr.as_str(), ClientHandler) .await .with_context(|| format!("connect {addr} failed"))?; - + tracing::debug!("[ssh] tcp connected to {}", addr); let authed = match session.auth { AuthMethod::Password => { - tracing::info!("[ssh] sending password authentication for {}@{}", session.user, addr); + tracing::info!( + "[ssh] sending password authentication for {}@{}", + session.user, + addr + ); let _ = events.send(BackendEvent::Status { tab_id: tab_id.to_string(), text: format!( @@ -251,7 +261,12 @@ async fn connect_and_authenticate( } AuthMethod::Key => { let source = key_source_label(session); - tracing::info!("[ssh] sending key authentication for {}@{} (key source: {})", session.user, addr, source); + tracing::info!( + "[ssh] sending key authentication for {}@{} (key source: {})", + session.user, + addr, + source + ); let _ = events.send(BackendEvent::Status { tab_id: tab_id.to_string(), text: format!("connected to {addr}, loading private key from {source}"), @@ -283,7 +298,11 @@ async fn connect_and_authenticate( if !success { return Err(anyhow::anyhow!( "public key authentication failed for {}@{}:{} using {} ({})", - session.user, session.host, session.port, source, algorithm + session.user, + session.host, + session.port, + source, + algorithm )); } success @@ -313,7 +332,11 @@ async fn connect_and_authenticate( )); } - tracing::info!("[ssh] authentication successful for {}@{}", session.user, addr); + tracing::info!( + "[ssh] authentication successful for {}@{}", + session.user, + addr + ); let _ = events.send(BackendEvent::Status { tab_id: tab_id.to_string(), @@ -376,7 +399,9 @@ fn private_keys_with_algs(keypair: PrivateKey) -> Result, } fn default_monitoring_position() -> String { @@ -246,6 +248,16 @@ impl ConfigStore { self.cache.locale = locale.to_string(); } + pub fn key_bindings(&self) -> &std::collections::HashMap { + &self.cache.key_bindings + } + + pub fn set_key_binding(&mut self, action_name: &str, keystroke: &str) { + self.cache + .key_bindings + .insert(action_name.to_string(), keystroke.to_string()); + } + pub fn monitoring_position(&self) -> &str { if self.cache.monitoring_position.is_empty() { "Sidebar" diff --git a/src/session/mod.rs b/src/session/mod.rs index 561a80e..f0a9b3f 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,13 +1,10 @@ pub mod config; use gpui::{ - App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, SharedString, Window, px, -}; -use gpui_component::{ - Theme, WindowExt as _, - input::InputState, + App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent, + MouseMoveEvent, SharedString, Window, px, }; +use gpui_component::{Theme, WindowExt as _, input::InputState}; use rust_i18n::t; use uuid::Uuid; @@ -15,9 +12,12 @@ use self::config::{AuthMethod, Session}; use crate::{ Ashell, ConnectionProgress, PaneLayout, SelectorEntry, TabGroup, + app::constants::{ + DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X, + TERMINAL_PADDING_Y, + }, backend::{local, ssh}, terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab}, - app::constants::{DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X, TERMINAL_PADDING_Y}, }; impl Ashell { @@ -223,7 +223,12 @@ impl Ashell { }; self.load_session_into_form(&session, window, cx); self.editing_session_id = None; - Self::set_input_value(&self.session_name_input, format!("{}-copy", session.name), window, cx); + Self::set_input_value( + &self.session_name_input, + format!("{}-copy", session.name), + window, + cx, + ); self.show_ssh_dialog(window, cx); } @@ -298,7 +303,10 @@ impl Ashell { } pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context) { - tracing::info!("[ui] user clicked to connect saved session '{}'", session_id); + tracing::info!( + "[ui] user clicked to connect saved session '{}'", + session_id + ); let Some(session) = self.config.get(&session_id).cloned() else { self.status = "saved session not found".into(); cx.notify(); @@ -342,7 +350,11 @@ impl Ashell { } } - pub(crate) fn activate_selector_selection(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn activate_selector_selection( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { let entries = self.selector_entries(); let Some(entry) = entries.get(self.selector_selection).cloned() else { return; @@ -392,7 +404,12 @@ impl Ashell { } pub(crate) fn open_ssh_session(&mut self, session: Session, cx: &mut Context) { - tracing::info!("[session] opening ssh tab for session '{}' ({}@{})", session.name, session.user, session.host); + tracing::info!( + "[session] opening ssh tab for session '{}' ({}@{})", + session.name, + session.user, + session.host + ); let id = Uuid::new_v4().to_string(); let backend = ssh::spawn_ssh_terminal( self.runtime.handle(), @@ -429,7 +446,12 @@ impl Ashell { self.active_group = Some(group_id.clone()); self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1); if let Some(session_id) = self.active_session_id() { - if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) { + if let Some(index) = self + .config + .sessions() + .iter() + .position(|s| s.id == session_id) + { self.saved_scroll_handle.scroll_to_item(index); } } @@ -496,15 +518,15 @@ impl Ashell { ); // Replace tab state in-place to reuse the UI component - self.tabs[ix] = TerminalTab::new_ssh( - tab_id.clone(), - &session, - backend, - self.events_tx.clone(), - ); + self.tabs[ix] = + TerminalTab::new_ssh(tab_id.clone(), &session, backend, self.events_tx.clone()); // Find group to restart SFTP - if let Some(group) = self.tab_groups.iter().find(|g| g.pane_root.contains(&tab_id)) { + if let Some(group) = self + .tab_groups + .iter() + .find(|g| g.pane_root.contains(&tab_id)) + { groups_to_restart_sftp.insert(group.id.clone()); } } @@ -513,8 +535,12 @@ impl Ashell { for group_id in groups_to_restart_sftp { if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) { // Use the session of any tab in that group - let group_session = self.tabs.iter().find(|t| group.pane_root.contains(&t.id) && t.session.is_some()).and_then(|t| t.session.clone()); - + let group_session = self + .tabs + .iter() + .find(|t| group.pane_root.contains(&t.id) && t.session.is_some()) + .and_then(|t| t.session.clone()); + if let Some(session) = group_session { if let Some(old_handle) = self.sftp_handles.remove(&group.id) { old_handle.close(); @@ -526,7 +552,7 @@ impl Ashell { self.events_tx.clone(), ); self.sftp_handles.insert(group.id.clone(), sftp_handle); - + if let Some(sftp) = group.sftp.as_mut() { sftp.status = rust_i18n::t!("sftp_connecting").to_string(); } @@ -549,7 +575,9 @@ impl Ashell { return; } self.connection_progress = None; - let tabs_to_close: Vec<_> = self.tabs.iter() + let tabs_to_close: Vec<_> = self + .tabs + .iter() .filter(|tab| !tab.connected && tab.session.is_some()) .map(|tab| tab.id.clone()) .collect(); @@ -569,7 +597,10 @@ impl Ashell { } self.active_tab = Some(id.clone()); // Find which group this tab belongs to and restore its pane_root - let tab_group = self.tab_groups.iter_mut().find(|g| g.pane_root.contains(&id)); + let tab_group = self + .tab_groups + .iter_mut() + .find(|g| g.pane_root.contains(&id)); if let Some(group) = tab_group { self.pane_root = group.pane_root.clone(); self.active_group = Some(group.id.clone()); @@ -584,7 +615,12 @@ impl Ashell { } if self.tabs.iter().any(|t| t.id == id) { if let Some(session_id) = self.active_session_id() { - if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) { + if let Some(index) = self + .config + .sessions() + .iter() + .position(|s| s.id == session_id) + { self.saved_scroll_handle.scroll_to_item(index); } } @@ -600,10 +636,16 @@ impl Ashell { } pub(crate) fn handle_tab_close(&mut self, id: String) { - let group_ix = self.tab_groups.iter().position(|g| g.pane_root.contains(&id)); + let group_ix = self + .tab_groups + .iter() + .position(|g| g.pane_root.contains(&id)); let Some(ref group) = group_ix.map(|i| self.tab_groups[i].clone()) else { // Fallback: find and close individual tab - tracing::info!("[handle_tab_close] no group found for tab '{}', closing individually", id); + tracing::info!( + "[handle_tab_close] no group found for tab '{}', closing individually", + id + ); if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) { self.tabs[ix].backend.send(BackendCommand::Close); self.tabs.remove(ix); @@ -616,7 +658,9 @@ impl Ashell { let is_group_close = pane_ids.len() <= 1; tracing::info!( "[handle_tab_close] id='{}' group_panes={:?} is_group_close={}", - id, pane_ids_str, is_group_close + id, + pane_ids_str, + is_group_close ); let was_active = self.active_tab.as_deref() == Some(id.as_str()); @@ -635,16 +679,31 @@ impl Ashell { let all_groups = &self.tab_groups; if let Some(pos) = all_groups.iter().position(|g| g.id == group.id) { if pos > 0 { - next_active_id = all_groups[pos - 1].pane_root.tab_ids().first().copied().map(String::from); + next_active_id = all_groups[pos - 1] + .pane_root + .tab_ids() + .first() + .copied() + .map(String::from); } else if pos + 1 < all_groups.len() { - next_active_id = all_groups[pos + 1].pane_root.tab_ids().first().copied().map(String::from); + next_active_id = all_groups[pos + 1] + .pane_root + .tab_ids() + .first() + .copied() + .map(String::from); } } } } if is_group_close { // Close all tabs in the group - let tab_ids: Vec = group.pane_root.tab_ids().iter().map(|s| s.to_string()).collect(); + let tab_ids: Vec = group + .pane_root + .tab_ids() + .iter() + .map(|s| s.to_string()) + .collect(); for tab_id in &tab_ids { if let Some(ix) = self.tabs.iter().position(|tab| tab.id == *tab_id) { self.tabs[ix].backend.send(BackendCommand::Close); @@ -662,7 +721,11 @@ impl Ashell { self.tabs[ix].backend.send(BackendCommand::Close); self.tabs.retain(|t| t.id != id); } - if let Some(g) = self.tab_groups.iter_mut().find(|g| g.pane_root.contains(&id)) { + if let Some(g) = self + .tab_groups + .iter_mut() + .find(|g| g.pane_root.contains(&id)) + { g.pane_root.remove_tab(&id); } self.pane_root.remove_tab(&id); @@ -687,9 +750,6 @@ impl Ashell { return; } - - - if was_active || self .active_tab @@ -698,12 +758,20 @@ impl Ashell { { // Activate next available pane let new_id = next_active_id.or_else(|| { - self.pane_root.tab_ids().first().copied().map(String::from) + self.pane_root + .tab_ids() + .first() + .copied() + .map(String::from) .or_else(|| self.tabs.first().map(|t| t.id.clone())) }); if let Some(new_id) = new_id { self.active_tab = Some(new_id.clone()); - if let Some(g) = self.tab_groups.iter().find(|g| g.pane_root.contains(&new_id)) { + if let Some(g) = self + .tab_groups + .iter() + .find(|g| g.pane_root.contains(&new_id)) + { self.active_group = Some(g.id.clone()); self.pane_root = g.pane_root.clone(); } @@ -815,12 +883,7 @@ impl Ashell { Self::resize_pane_tree(&mut self.tabs, &self.pane_root, total_cols, total_rows); } - fn resize_pane_tree( - tabs: &mut [TerminalTab], - layout: &PaneLayout, - cols: u16, - rows: u16, - ) { + fn resize_pane_tree(tabs: &mut [TerminalTab], layout: &PaneLayout, cols: u16, rows: u16) { match layout { PaneLayout::Single(id) => { if let Some(tab) = tabs.iter_mut().find(|t| t.id == *id) { @@ -905,12 +968,7 @@ impl Ashell { self.events_tx.clone(), ); self.sftp_handles.insert(new_id.clone(), sftp_handle); - TerminalTab::new_ssh( - new_id.clone(), - &session, - backend, - self.events_tx.clone(), - ) + TerminalTab::new_ssh(new_id.clone(), &session, backend, self.events_tx.clone()) } }; tab.resize(DEFAULT_COLS, DEFAULT_ROWS); @@ -939,7 +997,8 @@ impl Ashell { _ => return, }; - self.pane_root.replace_at(&self.focused_pane_path, split_layout); + self.pane_root + .replace_at(&self.focused_pane_path, split_layout); self.sync_pane_root_to_group(); // Update focused_pane_path: the new pane is at the indicated child index let parent_path = self.focused_pane_path.clone(); @@ -1001,7 +1060,11 @@ impl Ashell { } } - fn find_adjacent_pane(layout: &PaneLayout, path: &[usize], direction: &str) -> Option> { + fn find_adjacent_pane( + layout: &PaneLayout, + path: &[usize], + direction: &str, + ) -> Option> { if path.is_empty() { return None; } @@ -1023,7 +1086,11 @@ impl Ashell { if path.len() == 1 { // Direct child level if moves_in_this_split { - let delta: i32 = if direction == "up" || direction == "left" { -1 } else { 1 }; + let delta: i32 = if direction == "up" || direction == "left" { + -1 + } else { + 1 + }; let new_idx = idx as i32 + delta; if new_idx >= 0 && (new_idx as usize) < children.len() { let mut path = vec![new_idx as usize]; @@ -1037,17 +1104,26 @@ impl Ashell { } } else { // Recurse into child first - if let Some(mut child_path) = Self::find_adjacent_pane(&children[idx], &path[1..], direction) { + if let Some(mut child_path) = + Self::find_adjacent_pane(&children[idx], &path[1..], direction) + { child_path.insert(0, idx); Some(child_path) } else if moves_in_this_split { // Try sibling at this level - let delta: i32 = if direction == "up" || direction == "left" { -1 } else { 1 }; + let delta: i32 = if direction == "up" || direction == "left" { + -1 + } else { + 1 + }; let new_idx = idx as i32 + delta; if new_idx >= 0 && (new_idx as usize) < children.len() { let inner_idx = *path.get(1).unwrap_or(&0); let mut path = vec![new_idx as usize]; - path.extend(Self::leaf_at_index(&children[new_idx as usize], inner_idx)); + path.extend(Self::leaf_at_index( + &children[new_idx as usize], + inner_idx, + )); Some(path) } else { None @@ -1068,7 +1144,11 @@ impl Ashell { ) { // Save current group state if let Some(current_group_id) = self.active_group.clone() { - if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == current_group_id) { + if let Some(group) = self + .tab_groups + .iter_mut() + .find(|g| g.id == current_group_id) + { group.pane_root = self.pane_root.clone(); } } @@ -1111,7 +1191,8 @@ impl Ashell { } // Check if current system_tab_id is valid in this group - let is_current_valid = self.system_tab_id + let is_current_valid = self + .system_tab_id .as_ref() .map_or(false, |id| group_ssh_tabs.contains(id)); @@ -1187,15 +1268,20 @@ impl Ashell { match (layout, path) { (PaneLayout::Horizontal(_, _), []) => true, (PaneLayout::Vertical(_, _), []) => false, - (PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _), [first, rest @ ..]) => { - children.get(*first).map_or(false, |c| Self::is_layout_horizontal_at(c, rest)) - } + ( + PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _), + [first, rest @ ..], + ) => children + .get(*first) + .map_or(false, |c| Self::is_layout_horizontal_at(c, rest)), _ => false, } } fn adjust_split_ratio(layout: &mut PaneLayout, path: &[usize], _child_idx: usize, delta: f32) { - if let PaneLayout::Horizontal(children, ratio) | PaneLayout::Vertical(children, ratio) = layout { + if let PaneLayout::Horizontal(children, ratio) | PaneLayout::Vertical(children, ratio) = + layout + { if path.is_empty() { *ratio = (*ratio + delta).clamp(0.1, 0.9); } else { diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index 438ebb0..f93f3a8 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -258,7 +258,7 @@ async fn run_sftp( .canonicalize(".") .await .unwrap_or_else(|_| "/".to_string()); - + let _ = events.send(BackendEvent::SftpHome { tab_id: tab_id.clone(), home: home.clone(), @@ -575,10 +575,13 @@ async fn run_sftp( tokio::time::sleep(std::time::Duration::from_millis(500)).await; while let Ok(_) = rx.try_recv() {} // drain pending - if commands_tx_clone.send(SftpCommand::UploadEditedFile { - local_path: local_path.to_string_lossy().to_string(), - remote_path: remote_path.clone(), - }).is_err() { + if commands_tx_clone + .send(SftpCommand::UploadEditedFile { + local_path: local_path.to_string_lossy().to_string(), + remote_path: remote_path.clone(), + }) + .is_err() + { break; } } @@ -622,7 +625,11 @@ async fn run_sftp( let now = chrono::Local::now().format("%H:%M:%S"); let _ = events_clone.send(BackendEvent::SftpStatus { tab_id: tab_id_clone.clone(), - text: format!("{} ({})", t!("auto_saved_and_uploaded", base = base_name(&remote_path)), now), + text: format!( + "{} ({})", + t!("auto_saved_and_uploaded", base = base_name(&remote_path)), + now + ), }); } Err(err) => { @@ -642,16 +649,17 @@ async fn run_sftp( } else { path.clone() }; - + tracing::info!("[sftp] creating directory: '{}'", actual_path); match sftp.create_dir(&actual_path).await { Ok(_) => { let _ = events.send(BackendEvent::SftpStatus { tab_id: tab_id.clone(), - text: t!("create_folder_success", name = base_name(&actual_path)).to_string(), + text: t!("create_folder_success", name = base_name(&actual_path)) + .to_string(), }); - + // Re-fetch the parent directory to show the newly created folder if let Some(parent) = parent_dir(&actual_path) { let _ = commands_tx.send(SftpCommand::ListDir(parent)); @@ -741,21 +749,27 @@ fn recursive_delete<'a>( continue; } let child_path = crate::sftp::join_remote(&path, &name); - + let meta = entry.metadata(); let permissions = meta.permissions.unwrap_or(0); let is_dir = (permissions & 0o170_000) == 0o040_000; - + if is_dir { recursive_delete(sftp, child_path).await?; } else { - sftp.remove_file(&child_path).await.with_context(|| format!("Failed to delete file {child_path}"))?; + sftp.remove_file(&child_path) + .await + .with_context(|| format!("Failed to delete file {child_path}"))?; } } - sftp.remove_dir(&path).await.with_context(|| format!("Failed to delete dir {path}"))?; + sftp.remove_dir(&path) + .await + .with_context(|| format!("Failed to delete dir {path}"))?; } Err(_) => { - sftp.remove_file(&path).await.with_context(|| format!("Failed to delete {path}"))?; + sftp.remove_file(&path) + .await + .with_context(|| format!("Failed to delete {path}"))?; } } Ok(()) @@ -809,7 +823,9 @@ async fn connect_and_authenticate( break; } Ok(false) => { - tracing::debug!("[sftp] public key auth failed with algorithm, trying next"); + tracing::debug!( + "[sftp] public key auth failed with algorithm, trying next" + ); continue; } Err(e) => { @@ -819,7 +835,12 @@ async fn connect_and_authenticate( } } if !success { - return Err(anyhow!("public key authentication failed for {}@{}:{}", session.user, session.host, session.port)); + return Err(anyhow!( + "public key authentication failed for {}@{}:{}", + session.user, + session.host, + session.port + )); } success } @@ -894,7 +915,9 @@ fn private_keys_with_algs(keypair: PrivateKey) -> Result bool { let lower = filename.to_lowercase(); - let ext = std::path::Path::new(&lower).extension().and_then(|s| s.to_str()).unwrap_or(""); - let known_exts = ["txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", "css", "md", "toml", "csv", "log", "cfg"]; + let ext = std::path::Path::new(&lower) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let known_exts = [ + "txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", + "css", "md", "toml", "csv", "log", "cfg", + ]; if known_exts.contains(&ext) { return true; } @@ -22,19 +28,23 @@ pub(crate) fn is_editable_text_file(filename: &str) -> bool { impl Ashell { pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> { - self.active_group.as_ref() + self.active_group + .as_ref() .and_then(|id| self.tab_groups.iter().find(|g| &g.id == id)) .and_then(|g| g.sftp.as_ref()) } pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> { let active_id = self.active_group.clone()?; - self.tab_groups.iter_mut().find(|g| g.id == active_id) + self.tab_groups + .iter_mut() + .find(|g| g.id == active_id) .and_then(|g| g.sftp.as_mut()) } pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> { - self.active_group.as_ref() + self.active_group + .as_ref() .and_then(|id| self.sftp_handles.get(id)) } @@ -122,7 +132,11 @@ impl Ashell { } } - pub(crate) fn trigger_sftp_context_download(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn trigger_sftp_context_download( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { let Some(menu) = self.sftp_context_menu.take() else { return; }; @@ -161,7 +175,11 @@ impl Ashell { Ok(Ok(Some(mut paths))) => { if let Some(folder) = paths.pop() { let local_path = folder.to_string_lossy().to_string(); - tracing::info!("[sftp] initiating download of '{}' to '{}'", remote_path, local_path); + tracing::info!( + "[sftp] initiating download of '{}' to '{}'", + remote_path, + local_path + ); handle.download(remote_path, local_path); } } @@ -197,7 +215,11 @@ impl Ashell { Ok(Ok(Some(mut paths))) => { if let Some(file) = paths.pop() { let local_path = file.to_string_lossy().to_string(); - tracing::info!("[sftp] initiating upload of file '{}' to '{}'", local_path, remote_dir); + tracing::info!( + "[sftp] initiating upload of file '{}' to '{}'", + local_path, + remote_dir + ); handle.upload_paths(vec![local_path], remote_dir); } } @@ -233,7 +255,11 @@ impl Ashell { Ok(Ok(Some(mut paths))) => { if let Some(folder) = paths.pop() { let local_path = folder.to_string_lossy().to_string(); - tracing::info!("[sftp] initiating upload of folder '{}' to '{}'", local_path, remote_dir); + tracing::info!( + "[sftp] initiating upload of folder '{}' to '{}'", + local_path, + remote_dir + ); handle.upload_paths(vec![local_path], remote_dir); } } @@ -250,7 +276,12 @@ impl Ashell { .detach(); } - pub(crate) fn toggle_sftp_entry(&mut self, path: String, checked: bool, cx: &mut Context) { + pub(crate) fn toggle_sftp_entry( + &mut self, + path: String, + checked: bool, + cx: &mut Context, + ) { if let Some(sftp) = self.active_sftp_mut() { if checked { sftp.selected_entries.insert(path); @@ -275,7 +306,11 @@ impl Ashell { } } - pub(crate) fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn download_selected_sftp_entries( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { let Some(sftp) = self.active_sftp() else { return; }; @@ -299,7 +334,11 @@ impl Ashell { if let Ok(Ok(Some(mut paths))) = path_prompt.await { if let Some(folder) = paths.pop() { let local_dir = folder.to_string_lossy().to_string(); - tracing::info!("[sftp] initiating batch download of {} entries to '{}'", selected.len(), local_dir); + tracing::info!( + "[sftp] initiating batch download of {} entries to '{}'", + selected.len(), + local_dir + ); for remote in selected { let _ = handle.commands.send(crate::sftp::SftpCommand::Download { remote, @@ -326,7 +365,11 @@ impl Ashell { } if let Some(sftp) = self.active_sftp() { if let Some(handle) = self.active_sftp_handle() { - tracing::info!("[sftp] initiating batch upload of {} files to '{}'", paths.len(), sftp.current_path); + tracing::info!( + "[sftp] initiating batch upload of {} files to '{}'", + paths.len(), + sftp.current_path + ); let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths { locals: paths, remote_dir: sftp.current_path.clone(), diff --git a/src/terminal/custom_blocks.rs b/src/terminal/custom_blocks.rs index 9af6bd1..1668c97 100644 --- a/src/terminal/custom_blocks.rs +++ b/src/terminal/custom_blocks.rs @@ -3,9 +3,11 @@ use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size}; pub fn is_custom_block_supported(c: char) -> bool { match c as u32 { 0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements - 0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534 | 0x253C => true, // Light lines - 0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B | 0x254B => true, // Heavy lines - 0xE0B0..=0xE0B6 => true, // Powerline + 0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534 + | 0x253C => true, // Light lines + 0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B + | 0x254B => true, // Heavy lines + 0xE0B0..=0xE0B6 => true, // Powerline _ => false, } } @@ -101,85 +103,164 @@ pub fn paint_custom_block( // Light lines 0x2500 => paint_quad(x, cy - light / 2.0, w, light), // Horizontal 0x2502 => paint_quad(cx - light / 2.0, y, light, h), // Vertical - 0x250C => { // Down + Right - paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0); - paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light); + 0x250C => { + // Down + Right + paint_quad( + cx - light / 2.0, + cy - light / 2.0, + light, + h / 2.0 + light / 2.0, + ); + paint_quad( + cx - light / 2.0, + cy - light / 2.0, + w / 2.0 + light / 2.0, + light, + ); } - 0x2510 => { // Down + Left - paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0); + 0x2510 => { + // Down + Left + paint_quad( + cx - light / 2.0, + cy - light / 2.0, + light, + h / 2.0 + light / 2.0, + ); paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light); } - 0x2514 => { // Up + Right + 0x2514 => { + // Up + Right paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0); - paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light); + paint_quad( + cx - light / 2.0, + cy - light / 2.0, + w / 2.0 + light / 2.0, + light, + ); } - 0x2518 => { // Up + Left + 0x2518 => { + // Up + Left paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0); paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light); } - 0x251C => { // Vertical + Right + 0x251C => { + // Vertical + Right paint_quad(cx - light / 2.0, y, light, h); - paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light); + paint_quad( + cx - light / 2.0, + cy - light / 2.0, + w / 2.0 + light / 2.0, + light, + ); } - 0x2524 => { // Vertical + Left + 0x2524 => { + // Vertical + Left paint_quad(cx - light / 2.0, y, light, h); paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light); } - 0x252C => { // Horizontal + Down + 0x252C => { + // Horizontal + Down paint_quad(x, cy - light / 2.0, w, light); - paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0); + paint_quad( + cx - light / 2.0, + cy - light / 2.0, + light, + h / 2.0 + light / 2.0, + ); } - 0x2534 => { // Horizontal + Up + 0x2534 => { + // Horizontal + Up paint_quad(x, cy - light / 2.0, w, light); paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0); } - 0x253C => { // Vertical + Horizontal (Cross) + 0x253C => { + // Vertical + Horizontal (Cross) paint_quad(x, cy - light / 2.0, w, light); paint_quad(cx - light / 2.0, y, light, h); } - + // Heavy lines 0x2501 => paint_quad(x, cy - heavy / 2.0, w, heavy), // Heavy Horizontal 0x2503 => paint_quad(cx - heavy / 2.0, y, heavy, h), // Heavy Vertical - 0x250F => { // Heavy Down + Right - paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0); - paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy); + 0x250F => { + // Heavy Down + Right + paint_quad( + cx - heavy / 2.0, + cy - heavy / 2.0, + heavy, + h / 2.0 + heavy / 2.0, + ); + paint_quad( + cx - heavy / 2.0, + cy - heavy / 2.0, + w / 2.0 + heavy / 2.0, + heavy, + ); } - 0x2513 => { // Heavy Down + Left - paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0); + 0x2513 => { + // Heavy Down + Left + paint_quad( + cx - heavy / 2.0, + cy - heavy / 2.0, + heavy, + h / 2.0 + heavy / 2.0, + ); paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy); } - 0x2517 => { // Heavy Up + Right + 0x2517 => { + // Heavy Up + Right paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0); - paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy); + paint_quad( + cx - heavy / 2.0, + cy - heavy / 2.0, + w / 2.0 + heavy / 2.0, + heavy, + ); } - 0x251B => { // Heavy Up + Left + 0x251B => { + // Heavy Up + Left paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0); paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy); } - 0x2523 => { // Heavy Vertical + Right + 0x2523 => { + // Heavy Vertical + Right paint_quad(cx - heavy / 2.0, y, heavy, h); - paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy); + paint_quad( + cx - heavy / 2.0, + cy - heavy / 2.0, + w / 2.0 + heavy / 2.0, + heavy, + ); } - 0x252B => { // Heavy Vertical + Left + 0x252B => { + // Heavy Vertical + Left paint_quad(cx - heavy / 2.0, y, heavy, h); paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy); } - 0x2533 => { // Heavy Horizontal + Down + 0x2533 => { + // Heavy Horizontal + Down paint_quad(x, cy - heavy / 2.0, w, heavy); - paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0); + paint_quad( + cx - heavy / 2.0, + cy - heavy / 2.0, + heavy, + h / 2.0 + heavy / 2.0, + ); } - 0x253B => { // Heavy Horizontal + Up + 0x253B => { + // Heavy Horizontal + Up paint_quad(x, cy - heavy / 2.0, w, heavy); paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0); } - 0x254B => { // Heavy Vertical + Horizontal (Cross) + 0x254B => { + // Heavy Vertical + Horizontal (Cross) paint_quad(x, cy - heavy / 2.0, w, heavy); paint_quad(cx - heavy / 2.0, y, heavy, h); } // --- Powerline --- - 0xE0B0 => { // Rightward Solid Arrow + 0xE0B0 => { + // Rightward Solid Arrow let mut path = Path::new(point(x, y)); path.line_to(point(x + w, y + h / 2.0)); path.line_to(point(x, y + h)); @@ -187,7 +268,8 @@ pub fn paint_custom_block( window.paint_path(path, color); painted = true; } - 0xE0B2 => { // Leftward Solid Arrow + 0xE0B2 => { + // Leftward Solid Arrow let mut path = Path::new(point(x + w, y)); path.line_to(point(x, y + h / 2.0)); path.line_to(point(x + w, y + h)); @@ -195,7 +277,8 @@ pub fn paint_custom_block( window.paint_path(path, color); painted = true; } - 0xE0B1 => { // Rightward Line Arrow + 0xE0B1 => { + // Rightward Line Arrow let t = px(1.0); let mut p = Path::new(point(x, y)); p.line_to(point(x + w, y + h / 2.0)); @@ -211,7 +294,8 @@ pub fn paint_custom_block( window.paint_path(p2, color); painted = true; } - 0xE0B3 => { // Leftward Line Arrow + 0xE0B3 => { + // Leftward Line Arrow let t = px(1.0); let mut p = Path::new(point(x + w, y)); p.line_to(point(x, y + h / 2.0)); @@ -229,14 +313,16 @@ pub fn paint_custom_block( } // Half arches (Powerline rounded) - 0xE0B4 => { // Rightward Solid Semicircle + 0xE0B4 => { + // Rightward Solid Semicircle let mut path = Path::new(point(x, y)); path.curve_to(point(x, y + h), point(x + w * 2.0, y + h / 2.0)); path.line_to(point(x, y)); window.paint_path(path, color); painted = true; } - 0xE0B6 => { // Leftward Solid Semicircle + 0xE0B6 => { + // Leftward Solid Semicircle let mut path = Path::new(point(x + w, y)); path.curve_to(point(x + w, y + h), point(x - w, y + h / 2.0)); path.line_to(point(x + w, y)); diff --git a/src/terminal/element.rs b/src/terminal/element.rs index fe815a8..3c2b5b7 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -11,8 +11,8 @@ use gpui::{ use gpui_component::ActiveTheme as _; use crate::Ashell; +use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block}; use crate::terminal::{RenderSnapshot, ViewportSelection}; -use crate::terminal::custom_blocks::{paint_custom_block, is_custom_block_supported}; #[derive(Clone, Copy)] struct TerminalMetrics { @@ -230,9 +230,12 @@ impl InputHandler for TerminalInputHandler { _window: &mut Window, cx: &mut App, ) -> Option> { - self.view - .read(cx) - .terminal_ime_bounds_for_range(range_utf16, self.element_bounds, self.cell_width, self.line_height) + self.view.read(cx).terminal_ime_bounds_for_range( + range_utf16, + self.element_bounds, + self.cell_width, + self.line_height, + ) } fn character_index_for_point( @@ -342,7 +345,10 @@ impl TerminalElement { } } - fn layout_grid(&self, cx: &App) -> (Vec, Vec, Vec) { + fn layout_grid( + &self, + cx: &App, + ) -> (Vec, Vec, Vec) { let mut rects = Vec::new(); let mut runs = Vec::new(); let mut custom_blocks = Vec::new(); @@ -364,7 +370,11 @@ impl TerminalElement { rects.push(LayoutRect { row: render_cell.row, col: render_cell.col, - cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 }, + cells: if cell.flags.contains(Flags::WIDE_CHAR) { + 2 + } else { + 1 + }, color: if selected { cx.theme().selection } else if cell.flags.contains(Flags::INVERSE) { @@ -395,7 +405,11 @@ impl TerminalElement { c: cell.c, row: render_cell.row, col: render_cell.col, - cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 }, + cells: if cell.flags.contains(Flags::WIDE_CHAR) { + 2 + } else { + 1 + }, color: style.color, }); continue; @@ -521,8 +535,10 @@ impl Element for TerminalElement { } for block in &prepaint.custom_blocks { - let x = prepaint.bounds.origin.x.as_f32() + block.col as f32 * prepaint.metrics.cell_width.as_f32(); - let y = prepaint.bounds.origin.y.as_f32() + block.row as f32 * prepaint.metrics.line_height.as_f32(); + let x = prepaint.bounds.origin.x.as_f32() + + block.col as f32 * prepaint.metrics.cell_width.as_f32(); + let y = prepaint.bounds.origin.y.as_f32() + + block.row as f32 * prepaint.metrics.line_height.as_f32(); paint_custom_block( window, block.c, diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 3c9c304..b3ce385 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -8,9 +8,8 @@ use gpui::{ }; use crate::{ - Ashell, + Ashell, TerminalBacktabKey, TerminalTabKey, terminal::{BackendCommand, encode_key}, - TerminalBacktabKey, TerminalTabKey, }; impl Ashell { @@ -287,7 +286,11 @@ impl Ashell { } } - pub(crate) fn begin_terminal_selection(&mut self, event: &MouseDownEvent, cx: &mut Context) { + pub(crate) fn begin_terminal_selection( + &mut self, + event: &MouseDownEvent, + cx: &mut Context, + ) { let click_count = event.click_count.max(1); let selection_type = match click_count { 1 => SelectionType::Simple, @@ -419,7 +422,7 @@ impl Ashell { } let mode = tab.term.mode(); - + let is_mouse_tracking = mode.intersects( alacritty_terminal::term::TermMode::MOUSE_REPORT_CLICK | alacritty_terminal::term::TermMode::MOUSE_MOTION @@ -452,7 +455,8 @@ impl Ashell { } } if !bytes.is_empty() { - tab.backend.send(crate::terminal::BackendCommand::Input(bytes)); + tab.backend + .send(crate::terminal::BackendCommand::Input(bytes)); } } window.prevent_default(); @@ -466,7 +470,8 @@ impl Ashell { bytes.extend_from_slice(&[b'\x1b', b'O', code]); } if !bytes.is_empty() { - tab.backend.send(crate::terminal::BackendCommand::Input(bytes)); + tab.backend + .send(crate::terminal::BackendCommand::Input(bytes)); } window.prevent_default(); cx.stop_propagation(); diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 401abe8..4889f1f 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,6 +1,6 @@ +pub mod custom_blocks; pub mod element; pub mod input; -pub mod custom_blocks; use std::sync::mpsc::Sender; @@ -244,7 +244,11 @@ impl TerminalTab { if self.cols != new_cols || self.rows != new_rows { self.cols = new_cols; self.rows = new_rows; - tracing::info!("[ui] terminal resized to {}x{} (cols x rows)", self.cols, self.rows); + tracing::info!( + "[ui] terminal resized to {}x{} (cols x rows)", + self.cols, + self.rows + ); self.term.resize(TerminalSize::new(self.cols, self.rows)); self.backend.send(BackendCommand::Resize { cols, rows }); }