mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-29 00:01:13 +00:00
feat: add keybinding management
解决了 #18。 验证: - cargo fmt - cargo check - git diff --check
This commit is contained in:
+431
-349
@@ -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 {
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user