feat: add keybinding management

解决了 #18。

验证:

- cargo fmt

- cargo check

- git diff --check
This commit is contained in:
TomZz
2026-06-15 20:32:05 +08:00
parent 16761c1bf5
commit bd376935f4
18 changed files with 1962 additions and 952 deletions
+16
View File
@@ -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"
+16
View File
@@ -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}」使用,请选择其他快捷键"
+431 -349
View File
@@ -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<Self>) {
@@ -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<Self>) {
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<Self>) {
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 {
}))
)
)
)
)
}
})
+334
View File
@@ -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<String> {
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<String> {
config
.key_bindings()
.get(action_id)
.cloned()
.or_else(|| default_keystroke(action_id))
}
pub(crate) fn normalize_recorded_keystroke(event: &KeyDownEvent) -> Option<String> {
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<Ashell>, 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
}
}
+49 -28
View File
@@ -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<String>,
/// 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<f32>,
pub(crate) net_rx_history: Vec<f32>,
@@ -256,7 +261,7 @@ pub(crate) struct Ashell {
pub(crate) system_tab_id: Option<String>,
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
pub(crate) remote_sample_in_flight: bool,
pub(crate) runtime: Runtime,
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
@@ -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)),
))
}
}
+57 -31
View File
@@ -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<chrono::Local>) -> 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<f32> = workspace_panels_clone
.read(cx)
@@ -271,4 +298,3 @@ pub(crate) fn open_main_window(cx: &mut App) {
})
.expect("failed to open window");
}
+25 -11
View File
@@ -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<Self>) {
pub(crate) fn switch_theme_mode(
&mut self,
mode: ThemeMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.follow_system_theme = false;
self.theme_mode = mode;
self.apply_theme_preferences(window, cx);
@@ -48,7 +54,12 @@ impl Ashell {
cx.notify();
}
pub(crate) fn apply_theme(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn apply_theme(
&mut self,
name: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
self.status = format!("theme not found: {name}").into();
cx.notify();
@@ -84,7 +95,12 @@ impl Ashell {
cx.notify();
}
pub(crate) fn set_display_language(&mut self, locale: &str, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn set_display_language(
&mut self,
locale: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.config.set_locale(locale);
let mut active_locale = locale.to_string();
if active_locale == "system" {
@@ -143,6 +159,4 @@ impl Ashell {
tracing::warn!("failed to save theme preferences: {err:#}");
}
}
}
+561 -373
View File
File diff suppressed because it is too large Load Diff
+33 -8
View File
@@ -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<Vec<PrivateKeyWithHashA
}
if algs.is_empty() {
return Err(anyhow!("Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"));
return Err(anyhow!(
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
));
}
Ok(algs)
+17 -3
View File
@@ -1,6 +1,6 @@
#![windows_subsystem = "windows"]
use gpui::{ KeyBinding };
use gpui::KeyBinding;
use gpui_component_assets::Assets;
mod app;
@@ -14,6 +14,11 @@ rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession,
OpenSettings, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp, ToggleSftpZoom,
};
pub(crate) use app::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
@@ -37,9 +42,18 @@ fn main() {
app.run(move |cx| {
gpui_component::init(cx);
cx.bind_keys([
KeyBinding::new("tab", TerminalTabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
KeyBinding::new("shift-tab", TerminalBacktabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
KeyBinding::new(
"tab",
TerminalTabKey,
Some(app::constants::TERMINAL_KEY_CONTEXT),
),
KeyBinding::new(
"shift-tab",
TerminalBacktabKey,
Some(app::constants::TERMINAL_KEY_CONTEXT),
),
]);
app::startup::bind_workspace_keys(cx);
app::theme::load_embedded_themes(cx);
if let Err(err) = app::theme::load_fonts(cx) {
tracing::warn!("failed to load embedded fonts: {err:#}");
+12
View File
@@ -129,6 +129,8 @@ pub struct ConfigFile {
pub show_hidden_files: bool,
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
#[serde(default)]
pub key_bindings: std::collections::HashMap<String, String>,
}
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<String, String> {
&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"
+147 -61
View File
@@ -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<Self>) {
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<Self>) {
pub(crate) fn activate_selector_selection(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let entries = self.selector_entries();
let Some(entry) = entries.get(self.selector_selection).cloned() else {
return;
@@ -392,7 +404,12 @@ impl Ashell {
}
pub(crate) fn open_ssh_session(&mut self, session: Session, cx: &mut Context<Self>) {
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<String> = group.pane_root.tab_ids().iter().map(|s| s.to_string()).collect();
let tab_ids: Vec<String> = 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<Vec<usize>> {
fn find_adjacent_pane(
layout: &PaneLayout,
path: &[usize],
direction: &str,
) -> Option<Vec<usize>> {
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 {
+40 -18
View File
@@ -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<Vec<PrivateKeyWithHashA
}
if algs.is_empty() {
return Err(anyhow!("Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"));
return Err(anyhow!(
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
));
}
Ok(algs)
@@ -1351,7 +1374,6 @@ async fn upload_paths_impl(
}
}
// Create directories sequentially first
for dir in dirs_to_create {
create_remote_dir_all(sftp, &dir).await?;
+56 -13
View File
@@ -8,8 +8,14 @@ use crate::{
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
let lower = filename.to_lowercase();
let ext = std::path::Path::new(&lower).extension().and_then(|s| s.to_str()).unwrap_or("");
let known_exts = ["txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", "css", "md", "toml", "csv", "log", "cfg"];
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<Self>) {
pub(crate) fn trigger_sftp_context_download(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
@@ -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<Self>) {
pub(crate) fn toggle_sftp_entry(
&mut self,
path: String,
checked: bool,
cx: &mut Context<Self>,
) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
sftp.selected_entries.insert(path);
@@ -275,7 +306,11 @@ impl Ashell {
}
}
pub(crate) fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn download_selected_sftp_entries(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
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(),
+126 -40
View File
@@ -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));
+25 -9
View File
@@ -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<Bounds<Pixels>> {
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<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
fn layout_grid(
&self,
cx: &App,
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
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,
+11 -6
View File
@@ -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<Self>) {
pub(crate) fn begin_terminal_selection(
&mut self,
event: &MouseDownEvent,
cx: &mut Context<Self>,
) {
let click_count = event.click_count.max(1);
let selection_type = match click_count {
1 => SelectionType::Simple,
@@ -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();
+6 -2
View File
@@ -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 });
}