mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 00:00:59 +00:00
1446 lines
54 KiB
Rust
1446 lines
54 KiB
Rust
pub mod config;
|
|
|
|
use gpui::{
|
|
App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
|
|
MouseMoveEvent, SharedString, Window, px,
|
|
};
|
|
use gpui_component::{Theme, WindowExt as _, input::InputState};
|
|
use rust_i18n::t;
|
|
use uuid::Uuid;
|
|
|
|
use self::config::{AuthMethod, Session};
|
|
|
|
use crate::{
|
|
Ashell, PaneLayout, SelectorEntry, TabGroup,
|
|
app::constants::{
|
|
DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X,
|
|
TERMINAL_PADDING_Y,
|
|
},
|
|
backend::{local, ssh},
|
|
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
|
|
};
|
|
|
|
impl Ashell {
|
|
pub(crate) fn open_local(&mut self, cx: &mut Context<Self>) {
|
|
let id = Uuid::new_v4().to_string();
|
|
match local::spawn_local_terminal(
|
|
id.clone(),
|
|
DEFAULT_COLS,
|
|
DEFAULT_ROWS,
|
|
self.events_tx.clone(),
|
|
) {
|
|
Ok(backend) => {
|
|
let title = if cfg!(windows) { "PowerShell" } else { "Local" }.to_string();
|
|
let mut tab =
|
|
TerminalTab::new_local(id.clone(), title, backend, self.events_tx.clone());
|
|
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
|
|
self.tabs.push(tab);
|
|
self.active_tab = Some(id.clone());
|
|
self.pane_root = PaneLayout::Single(id.clone());
|
|
self.focused_pane_path = vec![];
|
|
let group_id = Uuid::new_v4().to_string();
|
|
self.tab_groups.push(TabGroup {
|
|
id: group_id.clone(),
|
|
title: "Local".to_string(),
|
|
pane_root: PaneLayout::Single(id),
|
|
sftp: None,
|
|
});
|
|
self.active_group = Some(group_id);
|
|
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
|
|
self.status = "local terminal opened".into();
|
|
}
|
|
Err(err) => {
|
|
self.status = format!("failed to open local terminal: {err:#}").into();
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
tracing::info!("[ui] user initiating new ssh connection from form");
|
|
let session_name = self.session_name_input.read(cx).value().trim().to_string();
|
|
let host = self.host_input.read(cx).value().trim().to_string();
|
|
let port = self
|
|
.port_input
|
|
.read(cx)
|
|
.value()
|
|
.trim()
|
|
.parse::<u16>()
|
|
.unwrap_or(22);
|
|
let user = self.user_input.read(cx).value().trim().to_string();
|
|
let password = self.password_input.read(cx).value().to_string();
|
|
let key_path = self.key_path_input.read(cx).value().trim().to_string();
|
|
let key_inline = self.key_inline_input.read(cx).value().to_string();
|
|
let passphrase = self.passphrase_input.read(cx).value().to_string();
|
|
|
|
if host.is_empty() || user.is_empty() {
|
|
self.status = t!("host_and_user_required").into();
|
|
cx.notify();
|
|
return;
|
|
}
|
|
|
|
if self.ssh_proxy_type != "none" {
|
|
let proxy_host = self.proxy_host_input.read(cx).value().trim().to_string();
|
|
let proxy_port_str = self.proxy_port_input.read(cx).value().trim().to_string();
|
|
let proxy_port = proxy_port_str.parse::<u16>().ok();
|
|
if proxy_host.is_empty() || proxy_port.is_none() {
|
|
self.status = "Proxy host and port are required".into();
|
|
cx.notify();
|
|
return;
|
|
}
|
|
}
|
|
|
|
let name = if session_name.is_empty() {
|
|
host.clone()
|
|
} else {
|
|
session_name
|
|
};
|
|
let existing_id = self.editing_session_id.clone();
|
|
let existing_last_used = existing_id
|
|
.as_deref()
|
|
.and_then(|id| self.config.get(id))
|
|
.and_then(|session| session.last_used.clone());
|
|
|
|
let mut session = match self.ssh_auth_method {
|
|
AuthMethod::Password => Session::password(host, port, user, password),
|
|
AuthMethod::Key => {
|
|
if key_path.is_empty() && key_inline.trim().is_empty() {
|
|
self.status = "private key path or content is required".into();
|
|
cx.notify();
|
|
return;
|
|
}
|
|
Session::key(host, port, user, key_path, key_inline, passphrase)
|
|
}
|
|
AuthMethod::KeyboardInteractive => Session::keyboard_interactive(host, port, user),
|
|
};
|
|
session.name = name;
|
|
if let Some(id) = existing_id {
|
|
session.id = id;
|
|
}
|
|
session.last_used = existing_last_used;
|
|
session.proxy_type = self.ssh_proxy_type.clone();
|
|
session.proxy_host = self.proxy_host_input.read(cx).value().trim().to_string();
|
|
session.proxy_port = self
|
|
.proxy_port_input
|
|
.read(cx)
|
|
.value()
|
|
.trim()
|
|
.parse::<u16>()
|
|
.ok();
|
|
session.proxy_user = self.proxy_user_input.read(cx).value().trim().to_string();
|
|
session.proxy_password = self.proxy_password_input.read(cx).value().to_string();
|
|
self.config.upsert(session.clone());
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save config: {err:#}");
|
|
}
|
|
|
|
self.open_ssh_session(session, cx);
|
|
self.editing_session_id = None;
|
|
self.active_dialog = None;
|
|
window.close_dialog(cx);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn set_input_value(
|
|
input: &Entity<InputState>,
|
|
value: impl Into<SharedString>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
input.update(cx, |state, cx| state.set_value(value, window, cx));
|
|
}
|
|
|
|
pub(crate) fn reset_ssh_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.editing_session_id = None;
|
|
self.ssh_auth_method = AuthMethod::Password;
|
|
Self::set_input_value(&self.session_name_input, "", window, cx);
|
|
Self::set_input_value(&self.host_input, "", window, cx);
|
|
Self::set_input_value(&self.port_input, "22", window, cx);
|
|
Self::set_input_value(&self.user_input, "root", window, cx);
|
|
Self::set_input_value(&self.password_input, "", window, cx);
|
|
Self::set_input_value(&self.key_path_input, "", window, cx);
|
|
Self::set_input_value(&self.key_inline_input, "", window, cx);
|
|
Self::set_input_value(&self.passphrase_input, "", window, cx);
|
|
self.ssh_proxy_type = "none".to_string();
|
|
Self::set_input_value(&self.proxy_host_input, "", window, cx);
|
|
Self::set_input_value(&self.proxy_port_input, "", window, cx);
|
|
Self::set_input_value(&self.proxy_user_input, "", window, cx);
|
|
Self::set_input_value(&self.proxy_password_input, "", window, cx);
|
|
}
|
|
|
|
pub(crate) fn load_session_into_form(
|
|
&mut self,
|
|
session: &Session,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.editing_session_id = Some(session.id.clone());
|
|
self.ssh_auth_method = session.auth;
|
|
Self::set_input_value(&self.session_name_input, session.name.clone(), window, cx);
|
|
Self::set_input_value(&self.host_input, session.host.clone(), window, cx);
|
|
Self::set_input_value(&self.port_input, session.port.to_string(), window, cx);
|
|
Self::set_input_value(&self.user_input, session.user.clone(), window, cx);
|
|
Self::set_input_value(&self.password_input, session.password.clone(), window, cx);
|
|
Self::set_input_value(
|
|
&self.key_path_input,
|
|
session.private_key_path.clone(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(
|
|
&self.key_inline_input,
|
|
session.private_key_inline.clone(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(
|
|
&self.passphrase_input,
|
|
session.passphrase.clone(),
|
|
window,
|
|
cx,
|
|
);
|
|
self.ssh_proxy_type = session.proxy_type.clone();
|
|
Self::set_input_value(&self.proxy_host_input, session.proxy_host.clone(), window, cx);
|
|
Self::set_input_value(
|
|
&self.proxy_port_input,
|
|
session.proxy_port.map(|p| p.to_string()).unwrap_or_default(),
|
|
window,
|
|
cx,
|
|
);
|
|
Self::set_input_value(&self.proxy_user_input, session.proxy_user.clone(), window, cx);
|
|
Self::set_input_value(&self.proxy_password_input, session.proxy_password.clone(), window, cx);
|
|
}
|
|
|
|
pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let start_dir = directories::BaseDirs::new()
|
|
.map(|d| d.home_dir().join(".ssh"))
|
|
.unwrap_or_else(|| std::path::PathBuf::from("/"));
|
|
|
|
let file_dialog = rfd::AsyncFileDialog::new()
|
|
.set_directory(start_dir)
|
|
.pick_file();
|
|
|
|
cx.spawn_in(window, async move |this, mut cx| {
|
|
if let Some(file) = file_dialog.await {
|
|
let _ = gpui::AsyncWindowContext::update(&mut cx, |window, cx| {
|
|
let _ = this.update(cx, |this, cx| {
|
|
Self::set_input_value(
|
|
&this.key_path_input,
|
|
file.path().to_string_lossy().to_string(),
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
Ok::<(), anyhow::Error>(())
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn open_new_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.reset_ssh_form(window, cx);
|
|
self.show_ssh_dialog(window, cx);
|
|
}
|
|
|
|
pub(crate) fn edit_saved_session(
|
|
&mut self,
|
|
session_id: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(session) = self.config.get(&session_id).cloned() else {
|
|
self.status = "saved session not found".into();
|
|
cx.notify();
|
|
return;
|
|
};
|
|
self.load_session_into_form(&session, window, cx);
|
|
self.show_ssh_dialog(window, cx);
|
|
}
|
|
|
|
pub(crate) fn clone_saved_session(
|
|
&mut self,
|
|
session_id: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(session) = self.config.get(&session_id).cloned() else {
|
|
self.status = "saved session not found".into();
|
|
cx.notify();
|
|
return;
|
|
};
|
|
self.load_session_into_form(&session, window, cx);
|
|
self.editing_session_id = None;
|
|
Self::set_input_value(
|
|
&self.session_name_input,
|
|
format!("{}-copy", session.name),
|
|
window,
|
|
cx,
|
|
);
|
|
self.show_ssh_dialog(window, cx);
|
|
}
|
|
|
|
pub(crate) fn terminal_cell_width(&self) -> f32 {
|
|
(self.terminal_font_size * 0.646).max(6.0)
|
|
}
|
|
|
|
pub(crate) fn terminal_line_height(&self) -> f32 {
|
|
(self.terminal_font_size * 1.385).max(self.terminal_font_size + 2.0)
|
|
}
|
|
|
|
pub(crate) fn change_terminal_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
|
self.terminal_font_size = (self.terminal_font_size + delta).clamp(10.0, 24.0);
|
|
self.config.set_terminal_font_size(self.terminal_font_size);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save terminal font size: {err:#}");
|
|
}
|
|
self.status = format!("terminal font size: {:.0}px", self.terminal_font_size).into();
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
|
self.ui_font_size = (self.ui_font_size + delta).clamp(8.0, 24.0);
|
|
self.config.set_ui_font_size(self.ui_font_size);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save UI font size: {err:#}");
|
|
}
|
|
Theme::global_mut(cx).font_size = px(self.ui_font_size);
|
|
self.status = format!("UI font size: {:.0}px", self.ui_font_size).into();
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn change_ui_font_family(
|
|
&mut self,
|
|
family: &str,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.ui_font_family = family.into();
|
|
self.config.set_ui_font_family(family);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save UI font family: {err:#}");
|
|
}
|
|
crate::app::theme::set_theme_font_names(Theme::global_mut(cx), &self.ui_font_family);
|
|
cx.notify();
|
|
window.refresh();
|
|
}
|
|
|
|
pub(crate) fn change_terminal_font_family(&mut self, family: &str, cx: &mut Context<Self>) {
|
|
self.terminal_font_family = family.into();
|
|
self.config.set_terminal_font_family(family);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save terminal font family: {err:#}");
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn change_cursor_style(
|
|
&mut self,
|
|
style: crate::session::config::CursorStyle,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.cursor_style = style;
|
|
self.config.set_cursor_style(style);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save cursor style: {err:#}");
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
self.config.set_layout_state(None, None, None);
|
|
let _ = self.config.save();
|
|
|
|
self.is_layout_reset = true;
|
|
self.workspace_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
|
|
self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn set_ssh_auth_method(&mut self, method: AuthMethod, cx: &mut Context<Self>) {
|
|
self.ssh_auth_method = method;
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn set_ssh_proxy_type(&mut self, proxy_type: String, cx: &mut Context<Self>) {
|
|
self.ssh_proxy_type = proxy_type;
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
|
|
tracing::info!(
|
|
"[ui] user clicked to connect saved session '{}'",
|
|
session_id
|
|
);
|
|
let Some(session) = self.config.get(&session_id).cloned() else {
|
|
self.status = "saved session not found".into();
|
|
cx.notify();
|
|
return;
|
|
};
|
|
self.open_ssh_session(session, cx);
|
|
}
|
|
|
|
pub(crate) fn selector_entries(&self) -> Vec<SelectorEntry> {
|
|
let mut entries = vec![SelectorEntry::Local, SelectorEntry::NewSsh];
|
|
entries.extend(
|
|
self.config
|
|
.sessions()
|
|
.iter()
|
|
.map(|session| SelectorEntry::Saved(session.id.clone())),
|
|
);
|
|
entries
|
|
}
|
|
|
|
pub(crate) fn default_selector_index(&self) -> usize {
|
|
if self.config.sessions().is_empty() {
|
|
0
|
|
} else {
|
|
2
|
|
}
|
|
}
|
|
|
|
pub(crate) fn move_selector_selection(&mut self, delta: i32, cx: &mut Context<Self>) {
|
|
let entries = self.selector_entries();
|
|
if entries.is_empty() {
|
|
return;
|
|
}
|
|
let current = self.selector_selection.min(entries.len().saturating_sub(1)) as i32;
|
|
let next = (current + delta).clamp(0, entries.len() as i32 - 1) as usize;
|
|
if next != self.selector_selection {
|
|
self.selector_selection = next;
|
|
if next >= 2 {
|
|
self.selector_scroll_handle.scroll_to_item(next - 2);
|
|
}
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn activate_selector_selection(
|
|
&mut self,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let entries = self.selector_entries();
|
|
let Some(entry) = entries.get(self.selector_selection).cloned() else {
|
|
return;
|
|
};
|
|
|
|
self.active_dialog = None;
|
|
match entry {
|
|
SelectorEntry::Local => {
|
|
self.open_local(cx);
|
|
window.close_dialog(cx);
|
|
}
|
|
SelectorEntry::NewSsh => {
|
|
window.close_dialog(cx);
|
|
self.open_new_ssh_dialog(window, cx);
|
|
}
|
|
SelectorEntry::Saved(session_id) => {
|
|
self.connect_saved_session(session_id, cx);
|
|
window.close_dialog(cx);
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn on_selector_key_down(
|
|
&mut self,
|
|
event: &KeyDownEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let key = event.keystroke.key.to_ascii_lowercase();
|
|
match key.as_str() {
|
|
"up" | "arrowup" => {
|
|
self.move_selector_selection(-1, cx);
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
"down" | "arrowdown" => {
|
|
self.move_selector_selection(1, cx);
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
"enter" | "return" => {
|
|
self.activate_selector_selection(window, cx);
|
|
window.prevent_default();
|
|
cx.stop_propagation();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn open_ssh_session(&mut self, session: Session, cx: &mut Context<Self>) {
|
|
tracing::info!(
|
|
"[session] opening ssh tab for session '{}' ({}@{})",
|
|
session.name,
|
|
session.user,
|
|
session.host
|
|
);
|
|
let id = Uuid::new_v4().to_string();
|
|
let backend = ssh::spawn_ssh_terminal(
|
|
self.runtime.handle(),
|
|
id.clone(),
|
|
session.clone(),
|
|
DEFAULT_COLS,
|
|
DEFAULT_ROWS,
|
|
self.events_tx.clone(),
|
|
);
|
|
self.tabs.push(TerminalTab::new_ssh(
|
|
id.clone(),
|
|
&session,
|
|
backend,
|
|
self.events_tx.clone(),
|
|
));
|
|
self.active_tab = Some(id.clone());
|
|
self.connection_progress = Some(crate::app::ConnectionProgress {
|
|
tab_id: id.clone(),
|
|
title: rust_i18n::t!("connecting").into(),
|
|
lines: vec![rust_i18n::t!("starting_connection").into()],
|
|
failed: false,
|
|
});
|
|
self.pane_root = PaneLayout::Single(id.clone());
|
|
self.focused_pane_path = vec![];
|
|
let group_id = Uuid::new_v4().to_string();
|
|
self.tab_groups.push(TabGroup {
|
|
id: group_id.clone(),
|
|
title: session.name.clone(),
|
|
pane_root: PaneLayout::Single(id.clone()),
|
|
sftp: Some(crate::terminal::SftpUiState {
|
|
current_path: "/".into(),
|
|
status: rust_i18n::t!("sftp_connecting").to_string(),
|
|
entries: Vec::new(),
|
|
selected_path: None,
|
|
preview: None,
|
|
selected_entries: std::collections::HashSet::new(),
|
|
home_dir: "/".into(),
|
|
}),
|
|
});
|
|
self.active_group = Some(group_id.clone());
|
|
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
|
|
if let Some(session_id) = self.active_session_id() {
|
|
if let Some(index) = self
|
|
.config
|
|
.sessions()
|
|
.iter()
|
|
.position(|s| s.id == session_id)
|
|
{
|
|
self.saved_scroll_handle.scroll_to_item(index);
|
|
}
|
|
}
|
|
cx.notify();
|
|
let sftp_handle = crate::sftp::spawn_sftp(
|
|
self.runtime.handle(),
|
|
group_id.clone(),
|
|
session,
|
|
self.events_tx.clone(),
|
|
);
|
|
self.sftp_handles.insert(group_id.clone(), sftp_handle);
|
|
self.active_tab = Some(id.clone());
|
|
self.pending_sftp_path_sync = Some("/".into());
|
|
self.status = "ssh tab opened".into();
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn remove_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
|
|
self.config.remove(&session_id);
|
|
if let Err(err) = self.config.save() {
|
|
tracing::warn!("failed to save config: {err:#}");
|
|
}
|
|
self.status = "session removed".into();
|
|
cx.notify();
|
|
}
|
|
|
|
/// Retry a single disconnected tab by its ID.
|
|
/// For SSH tabs: spawns a new SSH connection and restarts SFTP.
|
|
/// For local tabs: spawns a new local shell.
|
|
///
|
|
/// The existing `TerminalTab` (including its `term` scrollback history)
|
|
/// is preserved — only the backend is swapped via `set_backend()`.
|
|
pub(crate) fn retry_disconnected_tab(&mut self, tab_id: &str, cx: &mut Context<Self>) {
|
|
let Some(ix) = self.tabs.iter().position(|t| t.id == tab_id) else {
|
|
return;
|
|
};
|
|
if self.tabs[ix].connected || self.tabs[ix].disconnected_reason.is_none() {
|
|
return;
|
|
}
|
|
|
|
let is_ssh = self.tabs[ix].session.is_some();
|
|
let session = self.tabs[ix].session.clone();
|
|
let new_generation = self.tabs[ix].backend_generation + 1;
|
|
let cols = self.tabs[ix].cols;
|
|
let rows = self.tabs[ix].rows;
|
|
|
|
// Close old backend (sends Close through the shared Arc<Mutex>)
|
|
self.tabs[ix].send_backend(BackendCommand::Close);
|
|
|
|
if let Some(session) = session {
|
|
// SSH tab: spawn new SSH connection
|
|
let backend = ssh::spawn_ssh_terminal(
|
|
self.runtime.handle(),
|
|
tab_id.to_string(),
|
|
session.clone(),
|
|
cols,
|
|
rows,
|
|
self.events_tx.clone(),
|
|
);
|
|
|
|
// Swap the backend — the Term's internal listener shares the
|
|
// same Arc<Mutex<BackendTx>>, so user input is automatically
|
|
// routed to the new backend. Terminal history is preserved.
|
|
self.tabs[ix].set_backend(backend);
|
|
self.tabs[ix].connected = false;
|
|
self.tabs[ix].status = "connecting".into();
|
|
self.tabs[ix].disconnected_reason = None;
|
|
self.tabs[ix].backend_generation = new_generation;
|
|
self.tabs[ix].backend_initialized = false;
|
|
|
|
// Restart SFTP for the group containing this tab
|
|
if let Some(group) = self
|
|
.tab_groups
|
|
.iter()
|
|
.find(|g| g.pane_root.contains(tab_id))
|
|
{
|
|
let group_id = group.id.clone();
|
|
let group_session = self
|
|
.tabs
|
|
.iter()
|
|
.find(|t| group.pane_root.contains(&t.id) && t.session.is_some())
|
|
.and_then(|t| t.session.clone());
|
|
|
|
if let Some(session) = group_session {
|
|
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
|
|
old_handle.close();
|
|
}
|
|
let sftp_handle = crate::sftp::spawn_sftp(
|
|
self.runtime.handle(),
|
|
group_id.clone(),
|
|
session,
|
|
self.events_tx.clone(),
|
|
);
|
|
self.sftp_handles.insert(group_id.clone(), sftp_handle);
|
|
|
|
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
|
|
if let Some(sftp) = group.sftp.as_mut() {
|
|
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Local tab: spawn new local shell
|
|
match local::spawn_local_terminal(
|
|
tab_id.to_string(),
|
|
cols,
|
|
rows,
|
|
self.events_tx.clone(),
|
|
) {
|
|
Ok(backend) => {
|
|
// Swap the backend — preserves terminal history.
|
|
self.tabs[ix].set_backend(backend);
|
|
self.tabs[ix].connected = true;
|
|
self.tabs[ix].status = "local shell".into();
|
|
self.tabs[ix].disconnected_reason = None;
|
|
self.tabs[ix].backend_generation = new_generation;
|
|
self.tabs[ix].backend_initialized = false;
|
|
// Resize the new PTY to match the pane dimensions.
|
|
self.tabs[ix].send_backend(BackendCommand::Resize { cols, rows });
|
|
}
|
|
Err(err) => {
|
|
self.status = format!("failed to reopen local terminal: {err:#}").into();
|
|
cx.notify();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
self.status = if is_ssh {
|
|
"ssh tab retrying"
|
|
} else {
|
|
"local tab reopened"
|
|
}
|
|
.into();
|
|
cx.notify();
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn activate_tab(&mut self, id: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
// Save current group state
|
|
if let Some(group_id) = self.active_group.clone() {
|
|
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
|
|
group.pane_root = self.pane_root.clone();
|
|
}
|
|
}
|
|
self.active_tab = Some(id.clone());
|
|
// Find which group this tab belongs to and restore its pane_root
|
|
let tab_group = self
|
|
.tab_groups
|
|
.iter_mut()
|
|
.find(|g| g.pane_root.contains(&id));
|
|
if let Some(group) = tab_group {
|
|
self.pane_root = group.pane_root.clone();
|
|
self.active_group = Some(group.id.clone());
|
|
// Focus the activated tab in the pane tree
|
|
self.focus_pane_with_id(id.clone());
|
|
} else {
|
|
self.pane_root = PaneLayout::Single(id.clone());
|
|
self.focused_pane_path = vec![];
|
|
}
|
|
if let Some(index) = self.tabs.iter().position(|t| t.id == id) {
|
|
self.tabs_scroll_handle.scroll_to_item(index);
|
|
}
|
|
if self.tabs.iter().any(|t| t.id == id) {
|
|
if let Some(session_id) = self.active_session_id() {
|
|
if let Some(index) = self
|
|
.config
|
|
.sessions()
|
|
.iter()
|
|
.position(|s| s.id == session_id)
|
|
{
|
|
self.saved_scroll_handle.scroll_to_item(index);
|
|
}
|
|
}
|
|
}
|
|
self.focus_handle.focus(window, cx);
|
|
self.sync_system_tab_to_active_group();
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn close_tab(&mut self, id: String, cx: &mut Context<Self>) {
|
|
self.handle_tab_close(id);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn handle_tab_close(&mut self, id: String) {
|
|
let group_ix = self
|
|
.tab_groups
|
|
.iter()
|
|
.position(|g| g.pane_root.contains(&id));
|
|
let Some(ref group) = group_ix.map(|i| self.tab_groups[i].clone()) else {
|
|
// Fallback: find and close individual tab
|
|
tracing::info!(
|
|
"[handle_tab_close] no group found for tab '{}', closing individually",
|
|
id
|
|
);
|
|
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
|
|
self.tabs[ix].send_backend(BackendCommand::Close);
|
|
self.tabs.remove(ix);
|
|
}
|
|
return;
|
|
};
|
|
|
|
let pane_ids = group.pane_root.tab_ids();
|
|
let pane_ids_str: Vec<&str> = pane_ids.iter().map(|s| *s).collect();
|
|
let is_group_close = pane_ids.len() <= 1;
|
|
tracing::info!(
|
|
"[handle_tab_close] id='{}' group_panes={:?} is_group_close={}",
|
|
id,
|
|
pane_ids_str,
|
|
is_group_close
|
|
);
|
|
|
|
let was_active = self.active_tab.as_deref() == Some(id.as_str());
|
|
let mut next_active_id = None;
|
|
if was_active {
|
|
let tabs_in_group = group.pane_root.tab_ids();
|
|
if let Some(pos) = tabs_in_group.iter().position(|&s| s == id.as_str()) {
|
|
if pos > 0 {
|
|
next_active_id = Some(tabs_in_group[pos - 1].to_string());
|
|
} else if pos + 1 < tabs_in_group.len() {
|
|
next_active_id = Some(tabs_in_group[pos + 1].to_string());
|
|
}
|
|
}
|
|
if next_active_id.is_none() {
|
|
// Find next group's active tab
|
|
let all_groups = &self.tab_groups;
|
|
if let Some(pos) = all_groups.iter().position(|g| g.id == group.id) {
|
|
if pos > 0 {
|
|
next_active_id = all_groups[pos - 1]
|
|
.pane_root
|
|
.tab_ids()
|
|
.first()
|
|
.copied()
|
|
.map(String::from);
|
|
} else if pos + 1 < all_groups.len() {
|
|
next_active_id = all_groups[pos + 1]
|
|
.pane_root
|
|
.tab_ids()
|
|
.first()
|
|
.copied()
|
|
.map(String::from);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if is_group_close {
|
|
// Close all tabs in the group
|
|
let tab_ids: Vec<String> = group
|
|
.pane_root
|
|
.tab_ids()
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
for tab_id in &tab_ids {
|
|
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == *tab_id) {
|
|
self.tabs[ix].send_backend(BackendCommand::Close);
|
|
self.tabs.retain(|t| t.id != *tab_id);
|
|
}
|
|
}
|
|
if let Some(handle) = self.sftp_handles.remove(&group.id) {
|
|
handle.close();
|
|
}
|
|
self.tab_groups.remove(group_ix.unwrap());
|
|
self.pane_root.remove_tab(&id);
|
|
} else {
|
|
// Just remove this tab from the group
|
|
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
|
|
self.tabs[ix].send_backend(BackendCommand::Close);
|
|
self.tabs.retain(|t| t.id != id);
|
|
}
|
|
if let Some(g) = self
|
|
.tab_groups
|
|
.iter_mut()
|
|
.find(|g| g.pane_root.contains(&id))
|
|
{
|
|
g.pane_root.remove_tab(&id);
|
|
}
|
|
self.pane_root.remove_tab(&id);
|
|
self.sync_pane_root_to_group();
|
|
}
|
|
|
|
if self.tabs.is_empty() || self.tab_groups.is_empty() {
|
|
self.pane_root = PaneLayout::Single(String::new());
|
|
self.focused_pane_path = vec![];
|
|
self.active_tab = None;
|
|
self.active_group = None;
|
|
self.tab_groups.clear();
|
|
self.tabs.clear();
|
|
self.system_tab_id = None;
|
|
self.cpu_history.clear();
|
|
self.net_rx_history.clear();
|
|
self.net_tx_history.clear();
|
|
self.system_status = None;
|
|
for (_, handle) in self.sftp_handles.drain() {
|
|
handle.close();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if was_active
|
|
|| self
|
|
.active_tab
|
|
.as_ref()
|
|
.is_some_and(|active_id| !self.tabs.iter().any(|tab| &tab.id == active_id))
|
|
{
|
|
// Activate next available pane
|
|
let new_id = next_active_id.or_else(|| {
|
|
self.pane_root
|
|
.tab_ids()
|
|
.first()
|
|
.copied()
|
|
.map(String::from)
|
|
.or_else(|| self.tabs.first().map(|t| t.id.clone()))
|
|
});
|
|
if let Some(new_id) = new_id {
|
|
self.active_tab = Some(new_id.clone());
|
|
if let Some(g) = self
|
|
.tab_groups
|
|
.iter()
|
|
.find(|g| g.pane_root.contains(&new_id))
|
|
{
|
|
self.active_group = Some(g.id.clone());
|
|
self.pane_root = g.pane_root.clone();
|
|
}
|
|
self.focus_pane_with_id(new_id);
|
|
}
|
|
} else {
|
|
// Pane root structure may have changed (e.g. sibling removed), recalc path
|
|
if let Some(active_id) = self.active_tab.clone() {
|
|
self.focus_pane_with_id(active_id);
|
|
}
|
|
}
|
|
self.sync_system_tab_to_active_group();
|
|
}
|
|
|
|
pub(crate) fn focus_terminal(
|
|
&mut self,
|
|
event: &MouseDownEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// If the search bar is visible and the click is inside it, let the
|
|
// search bar handle the event instead of switching pane focus.
|
|
if self.search_active {
|
|
if let Some(bounds) = self.search_bar_bounds {
|
|
if bounds.contains(&event.position) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
self.focus_handle.focus(window, cx);
|
|
// Check if click is in a different pane and focus it
|
|
let click_pos = event.position;
|
|
let current_active = self.active_tab.clone();
|
|
let clicked_tab_id = self.terminal_bounds.iter().find_map(|(id, bounds)| {
|
|
if bounds.contains(&click_pos) {
|
|
Some(id.clone())
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
if let Some(tab_id) = clicked_tab_id {
|
|
if current_active.as_deref() != Some(tab_id.as_str()) {
|
|
self.focus_pane_with_id(tab_id.clone());
|
|
cx.notify();
|
|
}
|
|
}
|
|
if event.button == MouseButton::Left {
|
|
if self.config.right_click_copy_paste() {
|
|
if let Some(text) = self.active_terminal_selection_text() {
|
|
if !text.is_empty() {
|
|
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
|
if let Some(active_id) = &self.active_tab {
|
|
if let Some(tab) = self.tabs.iter_mut().find(|tab| &tab.id == active_id) {
|
|
tab.clear_selection();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.begin_terminal_selection(event, cx);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn active_snapshot(&self) -> Option<RenderSnapshot> {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
|
.map(TerminalTab::render_snapshot)
|
|
}
|
|
|
|
pub(crate) fn active_kind(&self) -> Option<TabKind> {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
|
.map(|tab| tab.kind)
|
|
}
|
|
|
|
pub(crate) fn active_title(&self) -> String {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
|
.map(|t| t.title.clone())
|
|
.unwrap_or_else(|| t!("idle_no_session").into())
|
|
}
|
|
|
|
pub(crate) fn active_ssh_session(&self) -> Option<(String, Session)> {
|
|
let active_id = self.active_tab.as_ref()?;
|
|
let tab = self.tabs.iter().find(|tab| &tab.id == active_id)?;
|
|
if !tab.connected {
|
|
return None;
|
|
}
|
|
Some((tab.id.clone(), tab.session.clone()?))
|
|
}
|
|
|
|
pub(crate) fn active_session_id(&self) -> Option<&str> {
|
|
self.active_tab
|
|
.as_ref()
|
|
.and_then(|id| self.tabs.iter().find(|tab| &tab.id == id))
|
|
.and_then(|tab| tab.session.as_ref())
|
|
.map(|session| session.id.as_str())
|
|
}
|
|
|
|
pub(crate) fn session_detail(&self, session: &Session) -> String {
|
|
format!("{}@{}:{}", session.user, session.host, session.port)
|
|
}
|
|
|
|
pub(crate) fn sync_terminal_size(&mut self, window: &Window, cx: &App) {
|
|
let viewport = window.viewport_size();
|
|
let sidebar_width = self
|
|
.workspace_panels
|
|
.read(cx)
|
|
.sizes()
|
|
.first()
|
|
.map(|size| size.as_f32())
|
|
.unwrap_or(SIDEBAR_WIDTH);
|
|
|
|
// Use the whole terminal panel bounds for PTY sizing.
|
|
// Individual pane bounds are smaller when split, so using them here
|
|
// would shrink the root PTY dimensions a second time.
|
|
let (width, height) = if let Some(bounds) = self.terminal_panel_bounds {
|
|
(bounds.size.width.as_f32(), bounds.size.height.as_f32())
|
|
} else {
|
|
let terminal_height = self
|
|
.body_panels
|
|
.read(cx)
|
|
.sizes()
|
|
.first()
|
|
.map(|size| size.as_f32())
|
|
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
|
|
(
|
|
(viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
|
|
.max(self.terminal_cell_width()),
|
|
(terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height()),
|
|
)
|
|
};
|
|
let total_cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16;
|
|
let total_rows = (height / self.terminal_line_height()).floor().max(1.0) as u16;
|
|
|
|
Self::resize_pane_tree(&mut self.tabs, &self.pane_root, total_cols, total_rows);
|
|
}
|
|
|
|
fn resize_pane_tree(tabs: &mut [TerminalTab], layout: &PaneLayout, cols: u16, rows: u16) {
|
|
match layout {
|
|
PaneLayout::Single(id) => {
|
|
if let Some(tab) = tabs.iter_mut().find(|t| t.id == *id) {
|
|
tab.resize(cols.max(1), rows.max(1));
|
|
}
|
|
}
|
|
PaneLayout::Horizontal(children, _) => {
|
|
let n = children.len() as u16;
|
|
let each_rows = (rows / n).max(1);
|
|
for child in children {
|
|
Self::resize_pane_tree(tabs, child, cols, each_rows);
|
|
}
|
|
}
|
|
PaneLayout::Vertical(children, _) => {
|
|
let n = children.len() as u16;
|
|
let each_cols = (cols / n).max(1);
|
|
for child in children {
|
|
Self::resize_pane_tree(tabs, child, each_cols, rows);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn split_current_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
|
|
tracing::info!(
|
|
"[split] direction={} pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
|
|
direction,
|
|
self.pane_root,
|
|
self.focused_pane_path,
|
|
self.active_tab,
|
|
self.tabs.len(),
|
|
);
|
|
let current_id = match self.pane_root.focused_tab_id(&self.focused_pane_path) {
|
|
Some(id) if !id.is_empty() => id.to_string(),
|
|
_ => return,
|
|
};
|
|
// Find current tab to clone its type/session
|
|
let current_tab = match self.tabs.iter().find(|t| t.id == current_id) {
|
|
Some(tab) => tab,
|
|
None => return,
|
|
};
|
|
let new_id = Uuid::new_v4().to_string();
|
|
let mut tab = match current_tab.kind {
|
|
TabKind::Local => {
|
|
match local::spawn_local_terminal(
|
|
new_id.clone(),
|
|
DEFAULT_COLS,
|
|
DEFAULT_ROWS,
|
|
self.events_tx.clone(),
|
|
) {
|
|
Ok(backend) => TerminalTab::new_local(
|
|
new_id.clone(),
|
|
"Local".into(),
|
|
backend,
|
|
self.events_tx.clone(),
|
|
),
|
|
Err(err) => {
|
|
self.status = format!("failed to split: {err:#}").into();
|
|
cx.notify();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
TabKind::Ssh => {
|
|
let Some(session) = current_tab.session.clone() else {
|
|
self.status = "cannot split: no session info".into();
|
|
cx.notify();
|
|
return;
|
|
};
|
|
let backend = ssh::spawn_ssh_terminal(
|
|
self.runtime.handle(),
|
|
new_id.clone(),
|
|
session.clone(),
|
|
DEFAULT_COLS,
|
|
DEFAULT_ROWS,
|
|
self.events_tx.clone(),
|
|
);
|
|
let sftp_handle = crate::sftp::spawn_sftp(
|
|
self.runtime.handle(),
|
|
new_id.clone(),
|
|
session.clone(),
|
|
self.events_tx.clone(),
|
|
);
|
|
self.sftp_handles.insert(new_id.clone(), sftp_handle);
|
|
TerminalTab::new_ssh(new_id.clone(), &session, backend, self.events_tx.clone())
|
|
}
|
|
};
|
|
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
|
|
// Do NOT add to tab_groups — pane stays within the existing group
|
|
self.tabs.push(tab);
|
|
// Do NOT scroll tab bar or add tab bar entry
|
|
|
|
let current_pane = PaneLayout::Single(current_id);
|
|
let new_pane = PaneLayout::Single(new_id.clone());
|
|
|
|
let split_layout = match direction {
|
|
"left" | "right" => {
|
|
let children = match direction {
|
|
"left" => vec![new_pane, current_pane],
|
|
_ => vec![current_pane, new_pane],
|
|
};
|
|
PaneLayout::Vertical(children, 0.5)
|
|
}
|
|
"up" | "down" => {
|
|
let children = match direction {
|
|
"up" => vec![new_pane, current_pane],
|
|
_ => vec![current_pane, new_pane],
|
|
};
|
|
PaneLayout::Horizontal(children, 0.5)
|
|
}
|
|
_ => return,
|
|
};
|
|
|
|
self.pane_root
|
|
.replace_at(&self.focused_pane_path, split_layout);
|
|
self.sync_pane_root_to_group();
|
|
// Update focused_pane_path: the new pane is at the indicated child index
|
|
let parent_path = self.focused_pane_path.clone();
|
|
let mut new_full_path = parent_path;
|
|
if direction == "right" || direction == "down" {
|
|
new_full_path.push(1);
|
|
} else {
|
|
new_full_path.push(0);
|
|
}
|
|
self.focused_pane_path = new_full_path;
|
|
self.active_tab = Some(new_id);
|
|
self.status = "pane split".into();
|
|
tracing::info!(
|
|
"[split] DONE: pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
|
|
self.pane_root,
|
|
self.focused_pane_path,
|
|
self.active_tab,
|
|
self.tabs.len(),
|
|
);
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn focus_adjacent_pane(&mut self, direction: &str) {
|
|
if self.focused_pane_path.is_empty() {
|
|
return;
|
|
}
|
|
let path = self.focused_pane_path.clone();
|
|
if let Some(new_path) = Self::find_adjacent_pane(&self.pane_root, &path, direction) {
|
|
self.focused_pane_path = new_path;
|
|
if let Some(id) = self.pane_root.focused_tab_id(&self.focused_pane_path) {
|
|
let id_owned = id.to_string();
|
|
let changed = self.active_tab.as_deref() != Some(id_owned.as_str());
|
|
self.active_tab = Some(id_owned);
|
|
// Clear stale search state when switching to a different pane.
|
|
if changed && self.search_active {
|
|
self.search_query.clear();
|
|
self.search_matches.clear();
|
|
self.search_current = 0;
|
|
self.search_target_tab = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn first_leaf_path(layout: &PaneLayout) -> Vec<usize> {
|
|
match layout {
|
|
PaneLayout::Single(_) => vec![],
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
let mut path = vec![0];
|
|
path.extend(Self::first_leaf_path(&children[0]));
|
|
path
|
|
}
|
|
}
|
|
}
|
|
|
|
fn leaf_at_index(layout: &PaneLayout, index: usize) -> Vec<usize> {
|
|
match layout {
|
|
PaneLayout::Single(_) => vec![],
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
if children.is_empty() {
|
|
return vec![];
|
|
}
|
|
let i = index.min(children.len() - 1);
|
|
let mut path = vec![i];
|
|
path.extend(Self::first_leaf_path(&children[i]));
|
|
path
|
|
}
|
|
}
|
|
}
|
|
|
|
fn find_adjacent_pane(
|
|
layout: &PaneLayout,
|
|
path: &[usize],
|
|
direction: &str,
|
|
) -> Option<Vec<usize>> {
|
|
if path.is_empty() {
|
|
return None;
|
|
}
|
|
match layout {
|
|
PaneLayout::Single(_) => None,
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
let is_horizontal = matches!(layout, PaneLayout::Horizontal(_, _));
|
|
let idx = path[0];
|
|
|
|
// Does this split level match the movement direction?
|
|
let vert = direction == "up" || direction == "down";
|
|
let horiz = direction == "left" || direction == "right";
|
|
// PaneLayout::Horizontal renders as v_flex (vertical stack),
|
|
// PaneLayout::Vertical renders as h_flex (horizontal row).
|
|
// So for a Vertical (h_flex), h/l moves between children;
|
|
// for a Horizontal (v_flex), j/k moves between children.
|
|
let moves_in_this_split = (vert && is_horizontal) || (horiz && !is_horizontal);
|
|
|
|
if path.len() == 1 {
|
|
// Direct child level
|
|
if moves_in_this_split {
|
|
let delta: i32 = if direction == "up" || direction == "left" {
|
|
-1
|
|
} else {
|
|
1
|
|
};
|
|
let new_idx = idx as i32 + delta;
|
|
if new_idx >= 0 && (new_idx as usize) < children.len() {
|
|
let mut path = vec![new_idx as usize];
|
|
path.extend(Self::first_leaf_path(&children[new_idx as usize]));
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
// Recurse into child first
|
|
if let Some(mut child_path) =
|
|
Self::find_adjacent_pane(&children[idx], &path[1..], direction)
|
|
{
|
|
child_path.insert(0, idx);
|
|
Some(child_path)
|
|
} else if moves_in_this_split {
|
|
// Try sibling at this level
|
|
let delta: i32 = if direction == "up" || direction == "left" {
|
|
-1
|
|
} else {
|
|
1
|
|
};
|
|
let new_idx = idx as i32 + delta;
|
|
if new_idx >= 0 && (new_idx as usize) < children.len() {
|
|
let inner_idx = *path.get(1).unwrap_or(&0);
|
|
let mut path = vec![new_idx as usize];
|
|
path.extend(Self::leaf_at_index(
|
|
&children[new_idx as usize],
|
|
inner_idx,
|
|
));
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn activate_group(
|
|
&mut self,
|
|
group_id: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// Save current group state
|
|
if let Some(current_group_id) = self.active_group.clone() {
|
|
if let Some(group) = self
|
|
.tab_groups
|
|
.iter_mut()
|
|
.find(|g| g.id == current_group_id)
|
|
{
|
|
group.pane_root = self.pane_root.clone();
|
|
}
|
|
}
|
|
// Load new group state
|
|
if let Some(group) = self.tab_groups.iter().find(|g| g.id == group_id) {
|
|
self.pane_root = group.pane_root.clone();
|
|
self.active_group = Some(group_id);
|
|
let ids = group.pane_root.tab_ids();
|
|
if let Some(&first_id) = ids.first() {
|
|
self.active_tab = Some(first_id.to_string());
|
|
self.focus_pane_with_id(first_id.to_string());
|
|
}
|
|
self.focus_handle.focus(window, cx);
|
|
}
|
|
self.sync_system_tab_to_active_group();
|
|
cx.notify();
|
|
}
|
|
|
|
pub(crate) fn sync_pane_root_to_group(&mut self) {
|
|
if let Some(group_id) = self.active_group.clone() {
|
|
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
|
|
group.pane_root = self.pane_root.clone();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn sync_system_tab_to_active_group(&mut self) {
|
|
let mut group_ssh_tabs = vec![];
|
|
if let Some(group_id) = &self.active_group {
|
|
if let Some(group) = self.tab_groups.iter().find(|g| g.id == *group_id) {
|
|
let ids = group.pane_root.tab_ids();
|
|
for id in ids {
|
|
if let Some(tab) = self.tabs.iter().find(|t| t.id == *id) {
|
|
if tab.kind == TabKind::Ssh && tab.connected {
|
|
group_ssh_tabs.push(tab.id.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if current system_tab_id is valid in this group
|
|
let is_current_valid = self
|
|
.system_tab_id
|
|
.as_ref()
|
|
.map_or(false, |id| group_ssh_tabs.contains(id));
|
|
|
|
if !is_current_valid {
|
|
let new_id = group_ssh_tabs.into_iter().next();
|
|
if self.system_tab_id != new_id {
|
|
self.system_tab_id = new_id;
|
|
self.cpu_history.clear();
|
|
self.net_rx_history.clear();
|
|
self.net_tx_history.clear();
|
|
self.remote_sample_in_flight = false;
|
|
if self.system_tab_id.is_none() {
|
|
self.system_status = Some("monitored session closed".to_string().into());
|
|
} else {
|
|
self.system_status = None;
|
|
}
|
|
self.request_active_system_snapshot();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn start_drag_split(
|
|
&mut self,
|
|
parent_path: Vec<usize>,
|
|
child_index: usize,
|
|
event: &MouseDownEvent,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Self>,
|
|
) {
|
|
self.dragging_splitter = Some((parent_path, child_index));
|
|
self.drag_split_origin = Some(event.position);
|
|
}
|
|
|
|
pub(crate) fn on_split_drag_move(
|
|
&mut self,
|
|
event: &MouseMoveEvent,
|
|
window: &mut Window,
|
|
_cx: &mut Context<Self>,
|
|
) {
|
|
let Some((ref parent_path, child_idx)) = self.dragging_splitter.clone() else {
|
|
return;
|
|
};
|
|
let Some(origin) = self.drag_split_origin else {
|
|
return;
|
|
};
|
|
let total = window.viewport_size();
|
|
let is_horizontal = Self::is_layout_horizontal_at(&self.pane_root, parent_path);
|
|
let delta: f32 = if is_horizontal {
|
|
(event.position.y - origin.y).into()
|
|
} else {
|
|
(event.position.x - origin.x).into()
|
|
};
|
|
let total_size: f32 = if is_horizontal {
|
|
total.height.into()
|
|
} else {
|
|
total.width.into()
|
|
};
|
|
if delta.abs() < 5.0 {
|
|
return; // dead zone
|
|
}
|
|
let ratio_delta = delta / total_size;
|
|
Self::adjust_split_ratio(&mut self.pane_root, parent_path, child_idx, ratio_delta);
|
|
self.drag_split_origin = Some(event.position);
|
|
self.sync_pane_root_to_group();
|
|
}
|
|
|
|
pub(crate) fn end_drag_split(&mut self) {
|
|
self.dragging_splitter = None;
|
|
self.drag_split_origin = None;
|
|
}
|
|
|
|
fn is_layout_horizontal_at(layout: &PaneLayout, path: &[usize]) -> bool {
|
|
match (layout, path) {
|
|
(PaneLayout::Horizontal(_, _), []) => true,
|
|
(PaneLayout::Vertical(_, _), []) => false,
|
|
(
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _),
|
|
[first, rest @ ..],
|
|
) => children
|
|
.get(*first)
|
|
.map_or(false, |c| Self::is_layout_horizontal_at(c, rest)),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn adjust_split_ratio(layout: &mut PaneLayout, path: &[usize], _child_idx: usize, delta: f32) {
|
|
if let PaneLayout::Horizontal(children, ratio) | PaneLayout::Vertical(children, ratio) =
|
|
layout
|
|
{
|
|
if path.is_empty() {
|
|
*ratio = (*ratio + delta).clamp(0.1, 0.9);
|
|
} else {
|
|
let (&first, rest) = path.split_first().unwrap();
|
|
if let Some(child) = children.get_mut(first) {
|
|
Self::adjust_split_ratio(child, rest, _child_idx, delta);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn focus_pane_with_id(&mut self, tab_id: String) {
|
|
// Find the path to the given tab_id in the pane tree
|
|
fn find_path(layout: &PaneLayout, target: &str, path: &mut Vec<usize>) -> bool {
|
|
match layout {
|
|
PaneLayout::Single(id) => id == target,
|
|
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
|
for (i, child) in children.iter().enumerate() {
|
|
path.push(i);
|
|
if find_path(child, target, path) {
|
|
return true;
|
|
}
|
|
path.pop();
|
|
}
|
|
false
|
|
}
|
|
}
|
|
}
|
|
let mut path = Vec::new();
|
|
if find_path(&self.pane_root, &tab_id, &mut path) {
|
|
let changed = self.active_tab.as_deref() != Some(tab_id.as_str());
|
|
self.focused_pane_path = path;
|
|
self.active_tab = Some(tab_id);
|
|
// Clear stale search state when switching to a different pane.
|
|
// The user can press Enter to re-search in the new pane.
|
|
if changed && self.search_active {
|
|
self.search_query.clear();
|
|
self.search_matches.clear();
|
|
self.search_current = 0;
|
|
self.search_target_tab = None;
|
|
}
|
|
}
|
|
}
|
|
}
|