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
+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