Files
ashell/src/app/ui.rs
T
TomZz bd376935f4 feat: add keybinding management
解决了 #18。

验证:

- cargo fmt

- cargo check

- git diff --check
2026-06-15 20:32:05 +08:00

2286 lines
108 KiB
Rust

use gpui::{
Context, ElementId, Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement,
MouseButton, MouseDownEvent, ParentElement as _, PathBuilder, Pixels, Render,
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, point,
prelude::FluentBuilder as _, px, rems, uniform_list,
};
use gpui_component::{
ActiveTheme, Disableable as _, ElementExt, IconName, Root, Sizable as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
h_flex,
input::Input,
menu::{ContextMenuExt as _, PopupMenuItem},
progress::Progress,
resizable::{h_resizable, resizable_panel, v_resizable},
scroll::{ScrollableElement as _, Scrollbar, ScrollbarShow},
tab::{Tab, TabBar},
v_flex,
};
use rust_i18n::t;
use crate::{
Ashell, PaneLayout,
app::constants::{SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT},
sftp::format_mtime,
sftp::ops::is_editable_text_file,
system::format_bytes,
terminal::{self, TabKind, TerminalTab},
};
impl Ashell {
fn render_home_page(&self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w_full()
.h_full()
.items_center()
.justify_center()
.gap_4()
.child(
div()
.text_size(rems(2.333))
.font_weight(FontWeight::BOLD)
.child("Ashell"),
)
.child(
div()
.text_size(rems(1.083))
.text_color(cx.theme().muted_foreground)
.child(t!("open_local_or_ssh")),
)
.child(
h_flex()
.gap_3()
.child(
Button::new("home-open-local")
.primary()
.label(t!("local_terminal").to_string())
.on_click(cx.listener(|this, _, _, cx| this.open_local(cx))),
)
.child(
Button::new("home-open-session")
.ghost()
.label(t!("open_session").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.show_selector_dialog(window, cx)
})),
),
)
}
pub(crate) fn toggle_sftp_minimized(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let state = self.body_panels.clone();
let minimized = self.sftp_panel_minimized;
if !minimized {
let sizes = state.read(cx).sizes();
if sizes.len() > 1 {
self.prev_monitoring_size = Some(sizes[1]);
}
self.sftp_panel_minimized = true;
} else {
self.sftp_panel_minimized = false;
let prev_size = self.prev_monitoring_size.unwrap_or(px(328.));
cx.on_next_frame(
window,
move |_this: &mut crate::app::Ashell,
window: &mut gpui::Window,
cx: &mut gpui::Context<crate::app::Ashell>| {
cx.on_next_frame(
window,
move |this: &mut crate::app::Ashell,
window: &mut gpui::Window,
cx: &mut gpui::Context<crate::app::Ashell>| {
this.body_panels.update(cx, |state, cx| {
let sizes = state.sizes();
let c_size_f32: f32 = sizes.iter().map(|s| s.as_f32()).sum();
let c_size = px(c_size_f32);
if c_size > px(0.0) && prev_size < c_size {
let target_p0 = c_size - prev_size;
state.resize_panel(0, target_p0, window, cx);
}
});
cx.notify();
},
);
},
);
}
cx.notify();
}
fn render_sftp_panel(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let active_sftp = self.active_sftp();
let header = h_flex()
.flex_none()
.h(px(34.))
.items_center()
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_bar)
.child(
div()
.text_size(rems(1.0))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().primary)
.child(t!("remote_files")),
)
.child(div().flex_1())
.when_some(active_sftp.clone(), |this, sftp| {
let selected_entries = sftp.selected_entries.clone();
this.child(
Button::new("sftp-refresh")
.ghost()
.small()
.icon(IconName::ArrowRight)
.label(t!("refresh").to_string())
.on_click(cx.listener(|this, _, _, cx| this.refresh_sftp(cx))),
)
.child(
Button::new("sftp-new-folder")
.ghost()
.small()
.icon(IconName::Folder)
.label(t!("new_folder").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.sftp_creating_folder = true;
this.sftp_new_folder_input.update(cx, |input, cx| {
input.set_value("", window, cx);
input.focus_handle(cx).focus(window, cx);
});
cx.notify();
})),
)
.child(
Button::new("sftp-delete-selected")
.ghost()
.small()
.icon(IconName::Close)
.label(if selected_entries.is_empty() {
t!("delete_selected").to_string()
} else {
format!(
"{} ({})",
t!("delete_selected").to_string(),
selected_entries.len()
)
})
.disabled(selected_entries.is_empty())
.on_click(cx.listener(|this, _, window, cx| {
this.show_delete_confirm_dialog(window, cx);
})),
)
.child(
Button::new("sftp-upload-file")
.ghost()
.small()
.icon(IconName::Plus)
.label(t!("upload_file").to_string())
.on_click(
cx.listener(|this, _, window, cx| this.upload_sftp_files(window, cx)),
),
)
.child(
Button::new("sftp-upload-folder")
.ghost()
.small()
.icon(IconName::Folder)
.label(t!("upload_folder").to_string())
.on_click(
cx.listener(|this, _, window, cx| this.upload_sftp_folder(window, cx)),
),
)
.child(
Button::new("sftp-download-selected")
.ghost()
.small()
.icon(IconName::ArrowDown)
.label(if selected_entries.is_empty() {
t!("download").to_string()
} else {
t!("download_count", count = selected_entries.len()).to_string()
})
.disabled(selected_entries.is_empty())
.on_click(cx.listener(|this, _, window, cx| {
this.download_selected_sftp_entries(window, cx);
})),
)
.child(
Checkbox::new("sftp-show-hidden")
.small()
.label(t!("hidden").to_string())
.checked(self.show_hidden_files)
.tab_stop(false)
.on_click(cx.listener(|this, checked, _, cx| {
this.show_hidden_files = *checked;
this.config.set_show_hidden_files(*checked);
let _ = this.config.save();
cx.notify();
})),
)
})
.child(
Button::new("open-transfers")
.ghost()
.small()
.icon(IconName::ArrowDown)
.label(t!("transfers").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.show_transfers_dialog(window, cx);
})),
)
.child(
Button::new("sftp-minimize-toggle")
.ghost()
.small()
.icon(if self.sftp_panel_minimized {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_sftp_minimized(window, cx);
})),
);
let Some(sftp) = active_sftp else {
let mut panel = v_flex()
.gap_0()
.border_color(cx.theme().border)
.bg(cx.theme().background);
if !self.sftp_panel_minimized {
panel = panel.size_full();
} else {
panel = panel.flex_none();
}
panel = panel.child(header);
if !self.sftp_panel_minimized {
panel = panel.child(
v_flex()
.flex_1()
.items_center()
.justify_center()
.p_3()
.child(
div()
.text_size(rems(1.0))
.text_color(cx.theme().muted_foreground)
.child(t!("open_ssh_tab_sftp")),
),
);
}
return panel.into_any_element();
};
let selected_path = sftp.selected_path.clone();
let entries = sftp
.entries
.clone()
.into_iter()
.filter(|entry| self.show_hidden_files || !entry.name.starts_with('.'))
.collect::<Vec<_>>();
let status = sftp.status.clone();
let selected_entries = sftp.selected_entries.clone();
let all_selected = !entries.is_empty()
&& entries
.iter()
.all(|e| selected_entries.contains(&e.full_path));
let parent_path = Self::sftp_parent_path(&sftp.current_path);
let view = cx.entity();
let icon_col_width = px(14.);
let size_col_width = px(96.);
let modified_col_width = px(152.);
let mut panel = v_flex()
.gap_0()
.border_color(cx.theme().border)
.bg(cx.theme().background);
if !self.sftp_panel_minimized {
panel = panel.size_full();
} else {
panel = panel.flex_none();
}
panel = panel
.on_drop(
cx.listener(|this, paths: &gpui::ExternalPaths, _window, cx| {
let paths_to_upload: Vec<String> = paths
.0
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
this.upload_sftp_files_batch(paths_to_upload, cx);
}),
)
.child(header);
if !self.sftp_panel_minimized {
panel = panel
.child(
h_flex()
.h(px(36.))
.items_center()
.gap_2()
.px_3()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().muted)
.child(
Button::new("sftp-up")
.ghost()
.small()
.icon(IconName::ChevronUp)
.on_click(cx.listener(move |this, _, _, cx| {
this.navigate_sftp(parent_path.clone(), cx);
})),
)
.child(Input::new(&self.sftp_path_input).flex_1().tab_index(0))
.child(div().flex_none()),
)
.child(
h_flex()
.h(px(26.))
.px_3()
.items_center()
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().muted.opacity(0.8))
.child(
h_flex()
.w(px(24.))
.flex_none()
.items_center()
.justify_center()
.child(
Checkbox::new("sftp-select-all")
.checked(all_selected)
.on_click(cx.listener(move |this, checked, _, cx| {
this.toggle_all_sftp_entries(*checked, cx);
})),
),
)
.child(
h_flex()
.flex_1()
.min_w(px(0.))
.items_center()
.gap_2()
.child(div().w(icon_col_width).flex_none())
.child(
div()
.flex_1()
.text_size(rems(0.917))
.text_color(cx.theme().muted_foreground)
.child(t!("name")),
),
)
.child(
div()
.w(size_col_width)
.flex_none()
.text_size(rems(0.917))
.text_color(cx.theme().muted_foreground)
.child(t!("size")),
)
.child(
div()
.w(modified_col_width)
.flex_none()
.text_size(rems(0.917))
.text_color(cx.theme().muted_foreground)
.child(t!("modified")),
),
)
.child(
div()
.flex_1()
.relative()
.min_h(px(0.))
.child({
let entries = entries.clone();
let selected_entries = selected_entries.clone();
let selected_path = selected_path.clone();
let view = view.clone();
let theme = cx.theme().clone();
let icon_col_width = icon_col_width;
let size_col_width = size_col_width;
let modified_col_width = modified_col_width;
uniform_list(
"sftp-entries-list",
entries.len(),
move |range, window, _cx| {
range
.into_iter()
.filter_map(|ix| {
let entry = entries.get(ix)?;
let entry = entry.clone();
let is_checked =
selected_entries.contains(&entry.full_path);
let is_selected = selected_path.as_deref()
== Some(entry.full_path.as_str());
let name_color = if entry.is_dir {
theme.primary
} else {
theme.foreground
};
let bg = if is_selected {
theme.secondary
} else if ix % 2 == 0 {
theme.background
} else {
theme.muted.opacity(0.5)
};
Some(
h_flex()
.w_full()
.h(px(28.))
.items_center()
.px_3()
.gap_2()
.bg(bg)
.hover(|style| style.bg(theme.muted.opacity(0.8)))
.border_b_1()
.border_color(theme.border.opacity(0.35))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&view, {
let entry = entry.clone();
move |this, _, _, cx| {
this.dismiss_sftp_context_menu(cx);
this.select_sftp_entry(
entry.clone(),
cx,
);
}
}),
)
.on_mouse_down(
MouseButton::Right,
window.listener_for(&view, {
let entry = entry.clone();
let remote_path = entry.full_path.clone();
move |this, event: &MouseDownEvent, _, cx| {
this.mark_sftp_entry_selected(
&entry.full_path,
cx,
);
this.open_sftp_context_menu(
remote_path.clone(),
entry.is_dir,
event.position,
cx,
);
}
}),
)
.child(
h_flex()
.w(px(24.))
.flex_none()
.items_center()
.justify_center()
.on_mouse_down(
MouseButton::Left,
|_, _, cx| cx.stop_propagation(),
)
.on_mouse_down(
MouseButton::Right,
|_, _, cx| cx.stop_propagation(),
)
.child(
Checkbox::new(ElementId::Name(
format!(
"check-{}",
entry.full_path
)
.into(),
))
.checked(is_checked)
.on_click(window.listener_for(&view, {
let path = entry.full_path.clone();
move |this, checked, _, cx| {
this.toggle_sftp_entry(
path.clone(),
*checked,
cx,
);
}
})),
),
)
.child(
h_flex()
.flex_1()
.min_w(px(0.))
.items_center()
.gap_2()
.child(
div()
.w(icon_col_width)
.flex_none()
.text_size(rems(1.0))
.text_color(name_color)
.child(if entry.is_dir {
"📁"
} else {
"📄"
}),
)
.child(
div()
.flex_1()
.min_w(px(0.))
.overflow_hidden()
.text_size(rems(1.0))
.text_color(name_color)
.child(entry.name),
),
)
.child(
div()
.w(size_col_width)
.flex_none()
.text_size(rems(0.917))
.text_color(theme.muted_foreground)
.child(if entry.is_dir {
"-".to_string()
} else {
format_bytes(entry.size)
}),
)
.child(
div()
.w(modified_col_width)
.flex_none()
.text_size(rems(0.917))
.text_color(theme.muted_foreground)
.child(format_mtime(entry.modified)),
)
.child(div().w(px(12.)).flex_none())
.into_any_element(),
)
})
.collect::<Vec<_>>()
},
)
.size_full()
.track_scroll(&self.remote_files_scroll_handle)
})
.child(
div()
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(16.))
.child(
Scrollbar::vertical(&self.remote_files_scroll_handle)
.scrollbar_show(ScrollbarShow::Always),
),
),
)
.child(
h_flex()
.flex_none()
.h(px(24.))
.px_3()
.items_center()
.border_t_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_bar)
.child(
div()
.min_w(px(0.))
.overflow_hidden()
.text_size(rems(0.917))
.text_color(cx.theme().muted_foreground)
.child(status),
),
);
}
panel.into_any_element()
}
fn render_monitoring_panel(
&mut self,
viewport_width: Pixels,
cx: &mut Context<Self>,
) -> impl IntoElement {
let cpu_color = cx.theme().chart_1;
let mem_color = cx.theme().chart_2;
let swap_color = cx.theme().chart_3;
let net_color = cx.theme().chart_4;
let disk_color = cx.theme().chart_5;
let border_color = cx.theme().border;
let muted_fg = cx.theme().muted_foreground;
let cpu_pct = self.system.cpu_percent;
// Dynamic CPU line color: green <30%, amber 30-80%, red >80%
// NOTE: Hsla.h is normalized 0..=1 (not degrees)
let cpu_path_color = {
let pct = cpu_pct * 100.0;
if pct < 30.0 {
Hsla {
h: 120.0 / 360.0,
s: 0.65,
l: 0.45,
a: 1.0,
}
} else if pct < 80.0 {
Hsla {
h: 45.0 / 360.0,
s: 0.8,
l: 0.55,
a: 1.0,
}
} else {
Hsla {
h: 0.0,
s: 0.8,
l: 0.55,
a: 1.0,
}
}
};
// Network TX color: derived from net_color for visual distinction from RX
let net_tx_color = if net_color.l > 0.5 {
Hsla {
l: net_color.l * 0.6,
..net_color
}
} else {
Hsla {
l: net_color.l * 1.5,
..net_color
}
};
let mem_pct = self.system.mem_percent;
let swap_pct = self.system.swap_percent;
let mem_detail = self.system.mem_detail.clone();
let swap_detail = self.system.swap_detail.clone();
let net_rx = self.system.net_rx.clone();
let net_tx = self.system.net_tx.clone();
let (disk_used, disk_total) = self.system.disks.iter().fold((0u64, 0u64), |(u, t), d| {
(u + (d.total_bytes - d.available_bytes), t + d.total_bytes)
});
let disk_pct = if disk_total > 0 {
disk_used as f64 / disk_total as f64 * 100.0
} else {
0.0
};
let cpu_spark_data = self.cpu_history.clone();
let net_rx_history = self.net_rx_history.clone();
let net_tx_history = self.net_tx_history.clone();
let disks = self.system.disks.clone();
let card_min_w = px(110.);
let show_net_card = viewport_width > px(750.);
let show_disk_card = viewport_width > px(600.);
// --- CPU card ---
let cpu_card = v_flex()
.min_w(card_min_w)
.flex_1()
.h_full()
.px_1()
.py_1()
.gap_0p5()
.child(
h_flex()
.w_full()
.items_center()
.child(
div()
.text_size(rems(0.833))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cpu_color)
.child(t!("cpu").to_string()),
)
.child(div().flex_1())
.child(
div()
.text_size(rems(0.833))
.text_color(muted_fg)
.child(format!("{:.0}%", cpu_pct * 100.0)),
),
)
.child(
canvas(
move |bounds, _window, _cx| {
let n = cpu_spark_data.len();
if n < 2 {
return None;
}
let mut path = PathBuilder::stroke(px(1.5));
let w = bounds.size.width;
let h = bounds.size.height;
let max_val = cpu_spark_data
.iter()
.cloned()
.fold(0.0f32, f32::max)
.max(0.1);
for (i, &val) in cpu_spark_data.iter().enumerate() {
let x = bounds.origin.x + w * i as f32 / (n - 1).max(1) as f32;
let y = bounds.origin.y + h * (1.0 - val / max_val * 0.85);
let pt = point(x, y);
if i == 0 {
path.move_to(pt);
} else {
path.line_to(pt);
}
}
path.build().ok()
},
move |_bounds, path_opt, window, _cx| {
if let Some(path) = path_opt {
window.paint_path(path, cpu_path_color);
}
},
)
.flex_1()
.w_full(),
);
// --- MEM card: mem + swap ---
let mem_card = v_flex()
.min_w(card_min_w)
.flex_1()
.h_full()
.px_1()
.py_1()
.gap_0p5()
.child(
h_flex()
.w_full()
.items_center()
.child(
div()
.text_size(rems(0.833))
.font_weight(FontWeight::SEMIBOLD)
.text_color(mem_color)
.child(t!("mem").to_string()),
)
.child(div().flex_1())
.child(
div()
.text_size(rems(0.833))
.text_color(muted_fg)
.child(format!("{:.0}%", mem_pct * 100.0)),
),
)
.child(
h_flex()
.w_full()
.items_center()
.gap_1()
.child(
Progress::new("mem-progress")
.value(mem_pct * 100.0)
.color(mem_color)
.with_size(px(5.))
.flex_1(),
)
.child(
div()
.text_size(rems(0.7))
.text_color(muted_fg)
.child(mem_detail),
),
)
.when(self.system.total_swap > 0, |this| {
this.child(
h_flex()
.w_full()
.items_center()
.gap_1()
.child(
Progress::new("swap-progress")
.value(swap_pct * 100.0)
.color(swap_color)
.with_size(px(4.))
.flex_1(),
)
.child(
div()
.text_size(rems(0.7))
.text_color(muted_fg)
.child(swap_detail),
),
)
});
// --- NET card: rx/tx text + dual sparkline ---
let net_card = if show_net_card {
Some(
v_flex()
.min_w(card_min_w)
.flex_1()
.h_full()
.px_1()
.py_1()
.gap_0p5()
.child(
h_flex()
.w_full()
.items_center()
.child(
div()
.text_size(rems(0.833))
.font_weight(FontWeight::SEMIBOLD)
.text_color(net_color)
.child(t!("net").to_string()),
)
.child(div().flex_1())
.child(
h_flex()
.gap_1()
.child(
div()
.text_size(rems(0.75))
.text_color(net_color)
.child(format!("↓{}", net_rx)),
)
.child(
div()
.text_size(rems(0.75))
.text_color(net_tx_color)
.child(format!("↑{}", net_tx)),
),
),
)
.child(
canvas(
move |bounds, _window, _cx| {
let n_rx = net_rx_history.len();
let n_tx = net_tx_history.len();
if n_rx < 2 || n_tx < 2 {
return None;
}
let all: Vec<f32> = net_rx_history
.iter()
.chain(net_tx_history.iter())
.cloned()
.collect();
let max_val = all.iter().cloned().fold(0.0f32, f32::max).max(1.0);
let w = bounds.size.width;
let h = bounds.size.height;
let mut paths = Vec::new();
let mut rx_path = PathBuilder::stroke(px(1.5));
for (i, &val) in net_rx_history.iter().enumerate() {
let x =
bounds.origin.x + w * i as f32 / (n_rx - 1).max(1) as f32;
let y = bounds.origin.y + h * (1.0 - val / max_val * 0.85);
let pt = point(x, y);
if i == 0 {
rx_path.move_to(pt);
} else {
rx_path.line_to(pt);
}
}
if let Ok(path) = rx_path.build() {
paths.push((path, net_color));
}
let mut tx_path = PathBuilder::stroke(px(1.0));
for (i, &val) in net_tx_history.iter().enumerate() {
let x =
bounds.origin.x + w * i as f32 / (n_tx - 1).max(1) as f32;
let y = bounds.origin.y + h * (1.0 - val / max_val * 0.85);
let pt = point(x, y);
if i == 0 {
tx_path.move_to(pt);
} else {
tx_path.line_to(pt);
}
}
if let Ok(path) = tx_path.build() {
paths.push((path, net_tx_color));
}
Some(paths)
},
move |_bounds, paths_opt, window, _cx| {
if let Some(paths) = paths_opt {
for (path, color) in paths {
window.paint_path(path, color);
}
}
},
)
.flex_1()
.w_full(),
),
)
} else {
None
};
// --- DISK card ---
let disk_card = if show_disk_card {
Some(
v_flex()
.min_w(card_min_w)
.flex_1()
.h_full()
.px_1()
.py_1()
.gap_0p5()
.child(
h_flex()
.w_full()
.items_center()
.child(
div()
.text_size(rems(0.833))
.font_weight(FontWeight::SEMIBOLD)
.text_color(disk_color)
.child(t!("disk").to_string()),
)
.child(div().flex_1())
.child(
div()
.text_size(rems(0.833))
.text_color(muted_fg)
.child(format!("{:.0}%", disk_pct)),
),
)
.child(
div()
.relative()
.flex_1()
.min_h(px(0.))
.child(
v_flex()
.id("disk-scroll")
.track_scroll(&self.disk_scroll_handle)
.overflow_y_scroll()
.size_full()
.children(disks.iter().map(|disk| {
let pct = if disk.total_bytes > 0 {
(disk.total_bytes - disk.available_bytes) as f64
/ disk.total_bytes as f64
* 100.0
} else {
0.0
};
let mount_short = disk.mount.clone();
let mount_id = format!("disk-{}", mount_short);
h_flex()
.w_full()
.items_center()
.gap_1()
.child(
div()
.text_size(rems(0.667))
.text_color(muted_fg)
.child(mount_short),
)
.child(
Progress::new(mount_id)
.value(pct as f32)
.color(disk_color)
.with_size(px(4.))
.flex_1(),
)
.child(
div()
.text_size(rems(0.667))
.text_color(muted_fg)
.child(format!("{:.0}%", pct)),
)
})),
)
.child(
div()
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(8.))
.child(
Scrollbar::vertical(&self.disk_scroll_handle)
.scrollbar_show(ScrollbarShow::Scrolling),
),
)
.into_any_element(),
)
.into_any_element(),
)
} else {
None
};
let mut panel = h_flex()
.h(px(80.))
.w_full()
.flex_none()
.px_3()
.gap_3()
.border_b_1()
.border_color(border_color)
.bg(cx.theme().muted);
panel = panel.child(cpu_card);
panel = panel.child(mem_card);
if let Some(card) = net_card {
panel = panel.child(card);
}
if let Some(card) = disk_card {
panel = panel.child(card);
}
panel
}
fn render_sidebar_monitoring_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
let cpu_pct = self.system.cpu_percent;
let mem_pct = self.system.mem_percent;
let swap_pct = self.system.swap_percent;
let cpu_color = cx.theme().chart_1;
let mem_color = cx.theme().chart_2;
let swap_color = cx.theme().chart_3;
let disk_color = cx.theme().chart_5;
let net_color = cx.theme().chart_4;
let muted_fg = cx.theme().muted_foreground;
v_flex()
.gap_4()
.w_full()
.p_2()
.child(
v_flex()
.gap_1()
.child(
h_flex()
.justify_between()
.child(
div()
.text_size(rems(0.85))
.text_color(cpu_color)
.child(t!("cpu").to_string()),
)
.child(
div()
.text_size(rems(0.85))
.text_color(muted_fg)
.child(format!("{:.1}%", cpu_pct * 100.0)),
),
)
.child(
Progress::new("sidebar-cpu")
.value(cpu_pct * 100.0)
.color(cpu_color)
.with_size(px(4.))
.w_full(),
),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.justify_between()
.child(
div()
.text_size(rems(0.85))
.text_color(mem_color)
.child(t!("mem").to_string()),
)
.child(
div()
.text_size(rems(0.85))
.text_color(muted_fg)
.child(self.system.mem_detail.clone()),
),
)
.child(
Progress::new("sidebar-mem")
.value(mem_pct * 100.0)
.color(mem_color)
.with_size(px(4.))
.w_full(),
),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.justify_between()
.child(
div()
.text_size(rems(0.85))
.text_color(swap_color)
.child(t!("swap").to_string()),
)
.child(
div()
.text_size(rems(0.85))
.text_color(muted_fg)
.child(self.system.swap_detail.clone()),
),
)
.child(
Progress::new("sidebar-swap")
.value(swap_pct * 100.0)
.color(swap_color)
.with_size(px(4.))
.w_full(),
),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.justify_between()
.items_center()
.child(
div()
.text_size(rems(0.85))
.text_color(disk_color)
.child(t!("disk").to_string()),
)
.children(if self.system.disks.len() > 3 {
Some(
div()
.text_size(rems(0.65))
.text_color(muted_fg)
.child(t!("scroll").to_string()),
)
} else {
None
}),
)
.child(
div()
.relative()
.w_full()
.child(
v_flex()
.id("sidebar-disk-scroll")
.track_scroll(&self.disk_scroll_handle)
.overflow_y_scroll()
.max_h(px(90.))
.gap_2()
.children(self.system.disks.iter().map(|disk| {
let pct = if disk.total_bytes > 0 {
(disk.total_bytes - disk.available_bytes) as f64
/ disk.total_bytes as f64
* 100.0
} else {
0.0
};
let mount_short = disk.mount.clone();
let mount_id = format!("sidebar-disk-{}", mount_short);
v_flex()
.gap_0p5()
.child(
h_flex()
.justify_between()
.child(
div()
.text_size(rems(0.75))
.text_color(muted_fg)
.child(mount_short),
)
.child(
div()
.text_size(rems(0.75))
.text_color(muted_fg)
.child(format!("{:.1}%", pct)),
),
)
.child(
Progress::new(mount_id)
.value(pct as f32)
.color(disk_color)
.with_size(px(4.))
.w_full(),
)
})),
)
.child(
div()
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(8.))
.child(
Scrollbar::vertical(&self.disk_scroll_handle)
.scrollbar_show(ScrollbarShow::Scrolling),
),
),
),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.justify_between()
.child(
div()
.text_size(rems(0.85))
.text_color(net_color)
.child(t!("net").to_string()),
)
.child(
div()
.text_size(rems(0.85))
.text_color(muted_fg)
.child(t!("live")),
),
)
.child(
h_flex()
.gap_2()
.child(
h_flex()
.flex_1()
.min_w(px(0.))
.gap_1()
.child(
div()
.flex_none()
.text_size(rems(0.75))
.text_color(net_color)
.child("↓"),
)
.child(
div()
.text_size(rems(0.75))
.child(self.system.net_rx.clone()),
),
)
.child(
h_flex()
.flex_1()
.min_w(px(0.))
.gap_1()
.child(
div()
.flex_none()
.text_size(rems(0.75))
.text_color(cx.theme().chart_5)
.child("↑"),
)
.child(
div()
.text_size(rems(0.75))
.child(self.system.net_tx.clone()),
),
),
),
)
}
fn sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let sessions = self.config.sessions().to_vec();
let active_session_id = self.active_session_id().map(ToOwned::to_owned);
v_flex()
.gap_4()
.w_full()
.h_full()
.min_w(px(0.))
.p_4()
.border_r_1()
.border_color(cx.theme().sidebar_border)
.bg(cx.theme().sidebar)
.overflow_hidden()
.child(
v_flex()
.gap_1()
.min_w(px(0.))
.child(
h_flex()
.items_center()
.gap_2()
.child(
div()
.font_weight(FontWeight::BOLD)
.text_size(rems(1.667))
.text_color(cx.theme().primary)
.child("Ashell"),
)
.child(div().flex_1())
.child(
Button::new("sidebar-settings")
.ghost()
.icon(IconName::Settings)
.on_click(cx.listener(|this, _, window, cx| {
this.show_settings_dialog(window, cx)
})),
),
)
.child(
div()
.text_size(rems(0.917))
.text_color(cx.theme().muted_foreground)
.child({
if let Some(kind) = self.active_kind() {
match kind {
TabKind::Local => t!("local_terminal").to_string(),
TabKind::Ssh => {
if let Some((_, session)) = self.active_ssh_session() {
format!("ssh / {}", session.name)
} else {
"ssh".to_string()
}
}
}
} else {
self.active_title()
}
}),
),
)
.when(self.config.monitoring_position() == "Sidebar", |this| {
this.child(self.render_sidebar_monitoring_panel(cx))
})
.child(
Button::new("open-ssh-panel")
.primary()
.label(t!("add_ssh").to_string())
.on_click(
cx.listener(|this, _, window, cx| this.open_new_ssh_dialog(window, cx)),
),
)
.child(
v_flex()
.flex_1()
.min_h(px(0.))
.gap_2()
.child(
div()
.text_size(rems(1.0))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().primary)
.child(t!("saved")),
)
.child(
div()
.relative()
.flex_1()
.min_h(px(0.))
.size_full()
.child(
v_flex()
.size_full()
.id("saved-sessions-scroll")
.track_scroll(&self.saved_scroll_handle)
.overflow_y_scroll()
.gap_2()
.children(sessions.into_iter().enumerate().map(
|(ix, session)| {
let connect_id = session.id.clone();
let edit_id = session.id.clone();
let delete_id = session.id.clone();
let is_active = active_session_id.as_deref()
== Some(session.id.as_str());
let name = session.name.clone();
let detail = self.session_detail(&session);
div()
.id(("saved-connect", ix))
.w_full()
.p_2()
.rounded_md()
.border_1()
.border_color(if is_active {
cx.theme().primary
} else {
cx.theme().border
})
.bg(if is_active {
cx.theme().tab_active
} else {
cx.theme().muted
})
.cursor_pointer()
.hover(|this| this.bg(cx.theme().secondary))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.connect_saved_session(
connect_id.clone(),
cx,
)
}),
)
.context_menu({
let view = cx.entity();
move |menu, window, _| {
let edit_value = edit_id.clone();
let clone_value = edit_id.clone();
let delete_value = delete_id.clone();
menu.item(
PopupMenuItem::new(
t!("clone").to_string(),
)
.on_click(window.listener_for(
&view,
move |this, _, window, cx| {
this.clone_saved_session(
clone_value.clone(),
window,
cx,
)
},
)),
)
.item(
PopupMenuItem::new(
t!("edit").to_string(),
)
.on_click(window.listener_for(
&view,
move |this, _, window, cx| {
this.edit_saved_session(
edit_value.clone(),
window,
cx,
)
},
)),
)
.item(
PopupMenuItem::new(
t!("delete").to_string(),
)
.on_click(window.listener_for(
&view,
move |this, _, _, cx| {
this.remove_saved_session(
delete_value.clone(),
cx,
)
},
)),
)
}
})
.child(
v_flex()
.gap_1()
.child(
div()
.text_size(rems(1.0))
.font_weight(FontWeight::SEMIBOLD)
.child(name),
)
.child(
div()
.text_size(rems(0.917))
.text_color(
cx.theme().muted_foreground,
)
.child(detail),
),
)
},
)),
)
.child(
div()
.absolute()
.top_0()
.bottom_0()
.left_0()
.right_0()
.child(
gpui_component::scroll::Scrollbar::new(
&self.saved_scroll_handle,
)
.id("saved-scrollbar")
.axis(gpui_component::scroll::ScrollbarAxis::Vertical)
.scrollbar_show(
gpui_component::scroll::ScrollbarShow::Always,
),
),
),
),
)
}
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let active_tab_index = self
.active_tab
.as_ref()
.and_then(|active_id| self.tabs.iter().position(|tab| &tab.id == active_id));
let active_group_index = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().position(|g| g.id == *gid));
let selected = active_group_index.unwrap_or(active_tab_index.unwrap_or(0));
let groups_data: Vec<(String, String, Vec<String>)> = self
.tab_groups
.iter()
.map(|g| {
let pane_ids: Vec<String> = g
.pane_root
.tab_ids()
.iter()
.map(|s| s.to_string())
.collect();
(g.id.clone(), g.title.clone(), pane_ids)
})
.collect();
v_flex()
.h(px(32.))
.w_full()
.flex_none()
.bg(cx.theme().tab_bar)
.child(
h_flex()
.h_full()
.w_full()
.items_center()
.gap_2()
.child(
div()
.flex_1()
.min_w(px(0.))
.h_full()
.overflow_x_hidden()
.child({
TabBar::new("ashell-tab-bar")
.track_scroll(&self.tabs_scroll_handle)
.selected_index(selected)
.children(groups_data.iter().enumerate().map(
|(ix, (group_id, title, pane_ids))| {
let gid = group_id.clone();
let label = if pane_ids.len() > 1 {
format!("{} ({})", title, pane_ids.len())
} else {
title.clone()
};
let close_id = if self.active_group.as_ref()
== Some(&gid)
{
self.active_tab.clone().unwrap_or_else(|| {
pane_ids.first().cloned().unwrap_or_default()
})
} else {
pane_ids.first().cloned().unwrap_or_default()
};
let dot_color = pane_ids
.first()
.and_then(|id| {
self.tabs.iter().find(|t| t.id == *id)
})
.map(|tab| {
if tab.connected {
cx.theme().success
} else {
cx.theme().danger
}
})
.unwrap_or(cx.theme().success);
Tab::new()
.min_w(px(80.))
.prefix(div().w(px(5.)).h(px(32.)).bg(dot_color))
.child(
div()
.when(ix == selected, |this| {
this.font_weight(FontWeight::BOLD)
.text_color(cx.theme().primary)
.text_base()
})
.child(label),
)
.on_click(cx.listener(
move |this, _, window, cx| {
this.activate_group(gid.clone(), window, cx)
},
))
.suffix(
Button::new(("tab-close", ix))
.ghost()
.xsmall()
.icon(IconName::Close)
.mr(px(5.))
.on_mouse_down(
MouseButton::Left,
|_, window, cx| {
window.prevent_default();
cx.stop_propagation();
},
)
.on_click(cx.listener(
move |this, _, window, cx| {
window.prevent_default();
cx.stop_propagation();
if !close_id.is_empty() {
this.close_tab(
close_id.clone(),
cx,
)
}
},
)),
)
},
))
.last_empty_space(div().w_3())
.w_full()
.h_full()
}),
)
.child(
h_flex()
.flex_none()
.items_center()
.gap_1()
.pr(px(6.))
.child(
Button::new("open-selector")
.secondary()
.small()
.rounded(px(999.))
.icon(IconName::Plus)
.on_click(cx.listener(|this, _, window, cx| {
this.show_selector_dialog(window, cx)
})),
)
.child(
Button::new("split-horizontal")
.secondary()
.small()
.rounded(px(999.))
.icon(IconName::PanelBottom)
.on_click(cx.listener(|this, _, window, cx| {
window.prevent_default();
cx.stop_propagation();
this.split_current_pane("down", cx);
})),
)
.child(
Button::new("split-vertical")
.secondary()
.small()
.rounded(px(999.))
.icon(IconName::PanelRight)
.on_click(cx.listener(|this, _, window, cx| {
window.prevent_default();
cx.stop_propagation();
this.split_current_pane("right", cx);
})),
),
),
)
}
fn render_terminal_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let has_active = self.active_tab.is_some();
let pane_tree = self.pane_root.clone();
v_flex().size_full().child(
div()
.size_full()
.overflow_hidden()
.track_focus(&self.focus_handle)
.key_context(TERMINAL_KEY_CONTEXT)
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
.on_mouse_down(
MouseButton::Right,
cx.listener(Self::on_terminal_right_click),
)
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
.on_key_down(cx.listener(Self::on_terminal_key_down))
.on_action(cx.listener(Self::on_terminal_tab_action))
.on_action(cx.listener(Self::on_terminal_backtab_action))
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
.child(if has_active {
Self::render_pane_tree(self, &pane_tree, &[], cx).into_any_element()
} else {
self.render_home_page(cx).into_any_element()
}),
)
}
fn render_pane_tree(
this: &mut Ashell,
layout: &PaneLayout,
path: &[usize],
cx: &mut Context<Ashell>,
) -> impl IntoElement {
match layout {
PaneLayout::Single(tab_id) => {
if tab_id.is_empty() {
return this.render_home_page(cx).into_any_element();
}
let is_focused = path == this.focused_pane_path.as_slice();
let snapshot = this
.tabs
.iter()
.find(|t| &t.id == tab_id)
.map(TerminalTab::render_snapshot);
let Some(snapshot) = snapshot else {
return div().into_any_element();
};
let view = cx.entity();
let tab_id_clone = tab_id.clone();
let tab_id_clone2 = tab_id.clone();
let focus_handle = this.focus_handle.clone();
let marked_text = if is_focused {
this.terminal_marked_text.clone()
} else {
None
};
let font_family = this.terminal_font_family.clone();
let font_size = px(this.terminal_font_size);
let line_height = px(this.terminal_line_height());
let cell_width = px(this.terminal_cell_width());
let mut el = div()
.size_full()
.overflow_hidden()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_bounds.insert(tab_id_clone.clone(), bounds);
});
})
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.focus_pane_with_id(tab_id_clone2.clone());
cx.notify();
}),
)
.child(terminal::element::TerminalElement::new(
cx.entity(),
focus_handle,
snapshot,
marked_text,
font_family,
font_size,
line_height,
cell_width,
));
let scrollbar = this.terminal_scrollbars.entry(tab_id.clone()).or_default();
el = el.vertical_scrollbar(scrollbar);
let indicator_color = this
.tabs
.iter()
.find(|t| t.id == *tab_id)
.map(|tab| {
if tab.connected {
cx.theme().success
} else {
cx.theme().danger
}
})
.unwrap_or(cx.theme().success);
let has_multiple_panes = this.pane_root.tab_ids().len() > 1;
if !is_focused {
el = el.opacity(0.85);
}
if has_multiple_panes {
if is_focused {
el = div()
.size_full()
.relative()
.child(
div()
.absolute()
.top(px(1.))
.left(px(1.))
.right(px(1.))
.h(px(1.))
.bg(indicator_color),
)
.child(
div()
.absolute()
.bottom(px(1.))
.left(px(1.))
.right(px(1.))
.h(px(1.))
.bg(indicator_color),
)
.child(
div()
.absolute()
.left(px(1.))
.top(px(1.))
.bottom(px(1.))
.w(px(1.))
.bg(indicator_color),
)
.child(
div()
.absolute()
.right(px(1.))
.top(px(1.))
.bottom(px(1.))
.w(px(1.))
.bg(indicator_color),
)
.p(px(4.))
.child(el);
} else {
el = div().size_full().p(px(4.)).child(el);
}
}
el.into_any_element()
}
PaneLayout::Horizontal(children, ratio) => {
v_flex()
.size_full()
.children(children.iter().enumerate().flat_map(|(i, child)| {
let mut items: Vec<gpui::AnyElement> = Vec::new();
if i > 0 {
let mut splitter_path = path.to_vec();
splitter_path.push(i - 1); // path to the pane BEFORE this splitter
items.push(
div()
.h(px(4.))
.w_full()
.flex_none()
.cursor_row_resize()
.bg(cx.theme().border)
.hover(|s| s.bg(cx.theme().accent))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event, window, cx| {
window.prevent_default();
cx.stop_propagation();
this.start_drag_split(
splitter_path.clone(),
i,
event,
window,
cx,
);
}),
)
.into_any_element(),
);
}
let mut child_path = path.to_vec();
child_path.push(i);
items.push(
div()
.flex_grow(if children.len() == 2 {
if i == 0 { *ratio } else { 1.0 - *ratio }
} else {
1.0
})
.min_h(px(0.))
.overflow_hidden()
.child(Self::render_pane_tree(this, child, &child_path, cx))
.into_any_element(),
);
items
}))
.into_any_element()
}
PaneLayout::Vertical(children, ratio) => h_flex()
.items_stretch()
.size_full()
.children(children.iter().enumerate().flat_map(|(i, child)| {
let mut items: Vec<gpui::AnyElement> = Vec::new();
if i > 0 {
let mut splitter_path = path.to_vec();
splitter_path.push(i - 1);
items.push(
div()
.w(px(4.))
.h_full()
.flex_none()
.cursor_col_resize()
.bg(cx.theme().border)
.hover(|s| s.bg(cx.theme().accent))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event, window, cx| {
window.prevent_default();
cx.stop_propagation();
this.start_drag_split(
splitter_path.clone(),
i,
event,
window,
cx,
);
}),
)
.into_any_element(),
);
}
let mut child_path = path.to_vec();
child_path.push(i);
items.push(
div()
.flex_grow(if children.len() == 2 {
if i == 0 { *ratio } else { 1.0 - *ratio }
} else {
1.0
})
.min_w(px(0.))
.overflow_hidden()
.child(Self::render_pane_tree(this, child, &child_path, cx))
.into_any_element(),
);
items
}))
.into_any_element(),
}
}
}
impl Render for Ashell {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self
.active_tab
.as_ref()
.is_some_and(|active_id| !self.tabs.iter().any(|tab| &tab.id == active_id))
{
self.active_tab = self.tabs.first().map(|tab| tab.id.clone());
}
self.sync_sftp_path_input(window, cx);
self.sync_terminal_size(window, cx);
if self.show_transfers_dialog {
self.show_transfers_dialog = false;
self.show_transfers_dialog(window, cx);
}
if let Some(active_id) = self.active_tab.clone() {
if let Some(scrollbar) = self.terminal_scrollbars.get(&active_id) {
if let Some(new_display_offset) = scrollbar.future_display_offset.take() {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
let current = tab.render_snapshot().display_offset;
match new_display_offset.cmp(&current) {
std::cmp::Ordering::Greater => {
tab.scroll_up_by(new_display_offset - current)
}
std::cmp::Ordering::Less => {
tab.scroll_down_by(current - new_display_offset)
}
std::cmp::Ordering::Equal => {}
}
}
}
}
if let Some(snapshot) = self.active_snapshot().as_ref() {
if let Some(scrollbar) = self.terminal_scrollbars.get(&active_id) {
scrollbar.update(snapshot, px(self.terminal_line_height()));
}
}
}
let sidebar_area = resizable_panel()
.size(px(self
.config
.workspace_panels()
.and_then(|s| s.first().copied())
.unwrap_or(SIDEBAR_WIDTH)))
.size_range(px(240.)..px(520.))
.flex_none()
.child(self.sidebar(cx));
let monitoring_contents = v_flex()
.size_full()
.when(self.config.monitoring_position() == "Bottom", |this| {
this.child(self.render_monitoring_panel(window.viewport_size().width, cx))
})
.child(self.render_sftp_panel(window, cx));
let is_monitor_bottom = self.config.monitoring_position() == "Bottom";
let minimized_height = if is_monitor_bottom { 114. } else { 34. };
let min_panel_height = if is_monitor_bottom { 260. } else { 180. };
let default_panel_height = if is_monitor_bottom { 328. } else { 248. };
let body_panel = if self.sftp_panel_minimized {
v_flex()
.size_full()
.child(
div().flex_1().min_h(px(0.)).child(
v_resizable("ashell-body")
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(cx))),
),
)
.child(
div()
.flex_none()
.h(px(minimized_height))
.w_full()
.border_t_1()
.border_color(cx.theme().border)
.child(monitoring_contents),
)
.into_any_element()
} else {
v_resizable("ashell-body")
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(cx)))
.child(
resizable_panel()
.size(px(self
.config
.body_panels()
.and_then(|s| s.get(1).copied())
.unwrap_or(default_panel_height)))
.size_range(px(min_panel_height)..px(1200.))
.child(monitoring_contents),
)
.into_any_element()
};
let main_area = resizable_panel().child(
v_flex()
.size_full()
.relative()
.overflow_hidden()
.child(self.render_tab_bar(cx))
.child(body_panel),
);
let workspace = h_resizable("ashell-workspace")
.with_state(&self.workspace_panels)
.child(sidebar_area)
.child(main_area);
div()
.id("ashell-root")
.size_full()
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.font_family(self.ui_font_family.clone())
.on_action(cx.listener(|this, _: &crate::OpenSettings, window, cx| this.show_settings_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::OpenSession, window, cx| this.show_selector_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::NewSsh, window, cx| this.show_ssh_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::ToggleSftpZoom, window, cx| {
this.toggle_sftp_minimized(window, cx);
}))
.on_action(cx.listener(|this, _: &crate::FocusPaneLeft, _, _| this.focus_adjacent_pane("left")))
.on_action(cx.listener(|this, _: &crate::FocusPaneRight, _, _| this.focus_adjacent_pane("right")))
.on_action(cx.listener(|this, _: &crate::FocusPaneUp, _, _| this.focus_adjacent_pane("up")))
.on_action(cx.listener(|this, _: &crate::FocusPaneDown, _, _| this.focus_adjacent_pane("down")))
.on_action(cx.listener(|this, _: &crate::SplitPaneLeft, _, cx| this.split_current_pane("left", cx)))
.on_action(cx.listener(|this, _: &crate::SplitPaneRight, _, cx| this.split_current_pane("right", cx)))
.on_action(cx.listener(|this, _: &crate::SplitPaneUp, _, cx| this.split_current_pane("up", cx)))
.on_action(cx.listener(|this, _: &crate::SplitPaneDown, _, cx| this.split_current_pane("down", cx)))
.on_action(cx.listener(|this, _: &crate::ClosePane, _, cx| {
if let Some(active_id) = this.active_tab.clone() {
this.close_tab(active_id, cx);
}
}))
.child(workspace)
.children(Root::render_dialog_layer(window, cx))
.children(Root::render_sheet_layer(window, cx))
.when_some(self.sftp_context_menu.clone(), |this, menu| {
let label = if menu.is_dir {
t!("download_folder").to_string()
} else {
t!("download").to_string()
};
this.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, _, cx| {
this.dismiss_sftp_context_menu(cx);
}),
)
.on_mouse_down(
MouseButton::Right,
cx.listener(|this, _, _, cx| {
this.dismiss_sftp_context_menu(cx);
}),
)
.child(
div()
.absolute()
.left(menu.position.x)
.top(menu.position.y)
.w(px(172.))
.p_1()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().popover)
.shadow_lg()
.on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_mouse_down(MouseButton::Right, |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.child(
v_flex()
.w_full()
.child(
Button::new("sftp-context-download")
.ghost()
.w_full()
.justify_start()
.label(label)
.on_click(cx.listener(|this, _, window, cx| {
this.trigger_sftp_context_download(window, cx);
})),
)
.when(
!menu.is_dir
&& is_editable_text_file(&menu.remote_path),
|this| {
this.child(
Button::new("sftp-context-edit")
.ghost()
.w_full()
.justify_start()
.label(t!("edit_file"))
.tooltip(
t!("edit_file_tooltip").to_string(),
)
.on_click(cx.listener(|this, _, _, cx| {
this.trigger_sftp_context_edit(cx);
})),
)
},
),
),
),
)
})
.when_some(self.connection_progress.clone(), |this, progress| {
this.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.bg(Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
a: 0.48,
})
.flex()
.items_center()
.justify_center()
.child(
div()
.w(px(420.))
.p_5()
.rounded_lg()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().popover)
.shadow_lg()
.child(
v_flex()
.gap_4()
.child(
Button::new("ssh-connect-progress")
.primary()
.loading(!progress.failed)
.label(progress.title.clone()),
)
.child(
div()
.relative()
.min_h(px(0.))
.max_h(px(220.))
.child(
div()
.id("connection-progress-scroll")
.max_h(px(220.))
.overflow_hidden()
.overflow_y_scroll()
.track_scroll(&self.connection_scroll_handle)
.child(
v_flex().gap_2().children(
progress.lines.iter().cloned().map(|line| {
div()
.text_size(rems(1.0))
.text_color(if progress.failed {
cx.theme().danger
} else {
cx.theme().muted_foreground
})
.child(line)
}),
),
)
)
.child(
div()
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(16.))
.child(
Scrollbar::vertical(&self.connection_scroll_handle)
.scrollbar_show(ScrollbarShow::Scrolling)
)
)
)
.when(progress.failed, |this| {
this.child(
h_flex()
.justify_end()
.gap_2()
.child(
Button::new("ssh-connect-progress-retry")
.primary()
.label(t!("retry").to_string())
.on_click(cx.listener(
|this, _, _, cx| {
this.retry_connection_progress(
cx,
)
},
)),
)
.child(
Button::new("ssh-connect-progress-close")
.label(t!("cancel").to_string())
.on_click(cx.listener(
|this, _, _, cx| {
this.cancel_connection_progress(
cx,
)
},
)),
),
)
}),
),
),
)
})
}
}