From 350dcbc5b4d416012992e2a2744dcdedaf740daa Mon Sep 17 00:00:00 2001 From: Realm Date: Fri, 21 Aug 2026 14:24:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8B=86=E5=88=86=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E4=B8=8E=E4=BC=9A=E8=AF=9D=E5=9F=BA=E7=A1=80=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 52 +- Cargo.toml | 8 + patches/stacksafe-macro/Cargo.toml | 16 + patches/stacksafe-macro/src/lib.rs | 73 +++ src/backend/local.rs | 97 ++- src/backend/serial.rs | 56 +- src/backend/ssh.rs | 530 ++++++++++++++- src/session/config.rs | 628 ++++++++++++++++-- src/session/mod.rs | 997 +++++++++++++++++++++++++---- src/sftp/mod.rs | 705 +++++++++++--------- src/sftp/ops.rs | 156 ++++- src/system/mod.rs | 163 ++++- src/terminal/element.rs | 40 +- src/terminal/highlight.rs | 12 +- src/terminal/input.rs | 257 +++++++- src/terminal/mod.rs | 416 +++++++++++- src/text_encoding.rs | 218 +++++++ 17 files changed, 3741 insertions(+), 683 deletions(-) create mode 100644 patches/stacksafe-macro/Cargo.toml create mode 100644 patches/stacksafe-macro/src/lib.rs create mode 100644 src/text_encoding.rs diff --git a/Cargo.lock b/Cargo.lock index aa30e44..28bbbd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,7 +324,9 @@ dependencies = [ "base64", "chacha20poly1305", "chrono", + "csv", "directories", + "encoding_rs", "flate2", "futures", "gpui", @@ -354,6 +356,7 @@ dependencies = [ "sys-locale", "sysinfo 0.33.1", "tar", + "tempfile", "thiserror 1.0.69", "time", "tokio", @@ -878,8 +881,7 @@ dependencies = [ [[package]] name = "block" version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +source = "git+https://github.com/Dicklesworthstone/rust-block?rev=b39ae859d1ee8e8cb5eef6a516471f1578d26b96#b39ae859d1ee8e8cb5eef6a516471f1578d26b96" [[package]] name = "block-buffer" @@ -1591,6 +1593,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "1.0.7" @@ -5561,28 +5584,6 @@ dependencies = [ "toml_edit 0.25.12+spec-1.1.0", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -7292,10 +7293,7 @@ dependencies = [ [[package]] name = "stacksafe-macro" version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" dependencies = [ - "proc-macro-error2", "quote", "syn", ] diff --git a/Cargo.toml b/Cargo.toml index 33007bf..db14fb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,9 @@ tokio-socks = "0.5" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" } async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } +csv = "1" directories = "5" +encoding_rs = "0.8" flate2 = "1" gpui = { git = "https://github.com/zed-industries/zed" } gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] } @@ -32,6 +34,7 @@ serde_json = "1" ssh-key = "0.6" sysinfo = "0.33" tar = "0.4" +tempfile = "3" thiserror = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] } tracing = "0.1" @@ -81,3 +84,8 @@ strip = true [build-dependencies] winres = "0.1.12" + +# Remove these overrides after GPUI moves off block 0.1 and stacksafe 0.1. +[patch.crates-io] +block = { git = "https://github.com/Dicklesworthstone/rust-block", rev = "b39ae859d1ee8e8cb5eef6a516471f1578d26b96" } +stacksafe-macro = { path = "patches/stacksafe-macro" } diff --git a/patches/stacksafe-macro/Cargo.toml b/patches/stacksafe-macro/Cargo.toml new file mode 100644 index 0000000..db1cf60 --- /dev/null +++ b/patches/stacksafe-macro/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "stacksafe-macro" +version = "0.1.4" +edition = "2021" +rust-version = "1.80.0" +description = "Procedural macro implementation for the stacksafe crate." +license = "Apache-2.0" +repository = "https://github.com/fast/stacksafe" +publish = false + +[lib] +proc-macro = true + +[dependencies] +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/patches/stacksafe-macro/src/lib.rs b/patches/stacksafe-macro/src/lib.rs new file mode 100644 index 0000000..1d656ba --- /dev/null +++ b/patches/stacksafe-macro/src/lib.rs @@ -0,0 +1,73 @@ +// Copyright 2025 FastLabs Developers +// Licensed under the Apache License, Version 2.0. + +//! Procedural macro implementation for the `stacksafe` crate. +//! +//! This local compatibility patch preserves the 0.1.4 API while replacing +//! the unmaintained `proc-macro-error2` dependency with native `syn` errors. + +use proc_macro::TokenStream; +use quote::{quote, ToTokens}; +use syn::{parse_macro_input, parse_quote, ItemFn, Path, ReturnType, Type}; + +#[proc_macro_attribute] +pub fn stacksafe(args: TokenStream, item: TokenStream) -> TokenStream { + let mut crate_path: Option = None; + + let arg_parser = syn::meta::parser(|meta| { + if meta.path.is_ident("crate") { + crate_path = Some(meta.value()?.parse()?); + Ok(()) + } else { + Err(meta.error(format!( + "unknown attribute parameter `{}`", + meta.path + .get_ident() + .map_or("unknown".to_string(), |ident| ident.to_string()) + ))) + } + }); + parse_macro_input!(args with arg_parser); + + let mut item_fn: ItemFn = match syn::parse(item) { + Ok(item_fn) => item_fn, + Err(error) => { + return syn::Error::new( + error.span(), + "#[stacksafe] can only be applied to functions", + ) + .into_compile_error() + .into(); + } + }; + + if let Some(asyncness) = &item_fn.sig.asyncness { + return syn::Error::new_spanned(asyncness, "#[stacksafe] does not support async functions") + .into_compile_error() + .into(); + } + + let block = item_fn.block; + let ret = match &item_fn.sig.output { + ReturnType::Type(_, ty) if matches!(**ty, Type::ImplTrait(_)) => ReturnType::Default, + _ => item_fn.sig.output.clone(), + }; + + let stacksafe_crate = crate_path.unwrap_or_else(|| parse_quote!(::stacksafe)); + let wrapped_block = quote! { + { + #stacksafe_crate::internal::stacker::maybe_grow( + #stacksafe_crate::get_minimum_stack_size(), + #stacksafe_crate::get_stack_allocation_size(), + #stacksafe_crate::internal::with_protected(move || #ret { #block }) + ) + } + }; + let wrapped_block = match syn::parse2(wrapped_block) { + Ok(block) => block, + Err(error) => return error.into_compile_error().into(), + }; + + item_fn.block = Box::new(wrapped_block); + item_fn.into_token_stream().into() +} diff --git a/src/backend/local.rs b/src/backend/local.rs index 32d53b4..bf45c19 100644 --- a/src/backend/local.rs +++ b/src/backend/local.rs @@ -1,19 +1,43 @@ use std::{ io::{Read, Write}, - sync::mpsc::{self, Sender}, + path::Path, + sync::mpsc, thread, + time::Duration, }; +#[cfg(not(windows))] +use std::time::Instant; + use anyhow::{Context, Result}; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +#[cfg(not(windows))] +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; -use crate::terminal::{BackendCommand, BackendEvent, BackendTx}; +use crate::terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender}; -pub fn spawn_local_terminal( +#[cfg(not(windows))] +const DIRECTORY_POLL_INTERVAL: Duration = Duration::from_secs(1); + +#[cfg(not(windows))] +fn local_process_directory(system: &mut System, pid: Pid) -> Option { + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + false, + ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always), + ); + system + .process(pid) + .and_then(|process| process.cwd()) + .map(std::path::PathBuf::from) +} + +pub fn spawn_local_terminal_at( tab_id: String, cols: u16, rows: u16, - events: Sender, + events: GuardedBackendEventSender, + initial_directory: Option<&Path>, ) -> Result { let pty_system = native_pty_system(); let pair = pty_system @@ -25,15 +49,27 @@ pub fn spawn_local_terminal( }) .context("open local PTY")?; - let shell = std::env::var("SHELL").unwrap_or_else(|_| { - if cfg!(windows) { - "powershell.exe".into() - } else { - "/bin/zsh".into() - } - }); + let shell = if cfg!(windows) { + "powershell.exe".to_string() + } else { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into()) + }; let mut cmd = CommandBuilder::new(&shell); + #[cfg(windows)] + { + const POWERSHELL_CWD_REPORTER: &str = r#"& { + $global:AshellOriginalPrompt = $function:prompt + function global:prompt { + $promptText = if ($global:AshellOriginalPrompt) { & $global:AshellOriginalPrompt } else { "PS $PWD> " } + $cwd = $PWD.ProviderPath + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cwd)) + [Console]::Write("$([char]27)]0;ASHELL_CWD_B64:$encoded$([char]7)") + $promptText + } + }"#; + cmd.args(["-NoLogo", "-NoExit", "-Command", POWERSHELL_CWD_REPORTER]); + } cmd.env( "TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()), @@ -54,8 +90,13 @@ pub fn spawn_local_terminal( if let Ok(home) = std::env::var("HOME") { cmd.env("HOME", home); } + if let Some(directory) = initial_directory.filter(|path| path.is_dir()) { + cmd.cwd(directory.as_os_str()); + } cmd.env("SHELL", shell); let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?; + #[cfg(not(windows))] + let child_pid = child.process_id().map(Pid::from_u32); drop(pair.slave); let master = pair.master; @@ -94,8 +135,15 @@ pub fn spawn_local_terminal( let write_tab = tab_id.clone(); let write_events = events.clone(); thread::spawn(move || { + #[cfg(not(windows))] + let mut process_system = System::new(); + #[cfg(not(windows))] + let mut last_directory = None; + #[cfg(not(windows))] + let mut last_directory_check = None; + loop { - match cmd_rx.recv_timeout(std::time::Duration::from_millis(100)) { + match cmd_rx.recv_timeout(Duration::from_millis(100)) { Ok(command) => match command { BackendCommand::Input(bytes) => { if let Err(err) = writer.write_all(&bytes) { @@ -116,7 +164,10 @@ pub fn spawn_local_terminal( }); } BackendCommand::Close => break, - BackendCommand::SampleMetrics => {} + BackendCommand::SampleMetrics + | BackendCommand::SampleProcesses + | BackendCommand::SamplePorts + | BackendCommand::TerminateProcess { .. } => {} }, Err(mpsc::RecvTimeoutError::Timeout) => { if let Ok(Some(status)) = child.try_wait() { @@ -129,6 +180,26 @@ pub fn spawn_local_terminal( } Err(mpsc::RecvTimeoutError::Disconnected) => break, } + + #[cfg(not(windows))] + { + if last_directory_check.is_none_or(|checked_at: Instant| { + checked_at.elapsed() >= DIRECTORY_POLL_INTERVAL + }) { + last_directory_check = Some(Instant::now()); + if let Some(directory) = + child_pid.and_then(|pid| local_process_directory(&mut process_system, pid)) + { + if last_directory.as_ref() != Some(&directory) { + last_directory = Some(directory.clone()); + let _ = write_events.send(BackendEvent::LocalDirectoryChanged { + tab_id: write_tab.clone(), + path: directory, + }); + } + } + } + } } let _ = child.kill(); }); diff --git a/src/backend/serial.rs b/src/backend/serial.rs index 1e0c8fa..bc416d6 100644 --- a/src/backend/serial.rs +++ b/src/backend/serial.rs @@ -1,5 +1,5 @@ use crate::session::config::Session; -use crate::terminal::{BackendCommand, BackendEvent}; +use crate::terminal::{BackendCommand, BackendEvent, GuardedBackendEventSender}; use std::io::{Read, Write}; /// Spawn the serial port backend threads. @@ -8,7 +8,7 @@ pub fn spawn_serial_client( _handle: &tokio::runtime::Handle, tab_id: String, session: Session, - events_tx: std::sync::mpsc::Sender, + events_tx: GuardedBackendEventSender, ) -> tokio::sync::mpsc::UnboundedSender { let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -91,7 +91,11 @@ pub fn spawn_serial_client( let _ = port_write.flush(); } BackendCommand::Close => break, - _ => {} + BackendCommand::Resize { .. } + | BackendCommand::SampleMetrics + | BackendCommand::SampleProcesses + | BackendCommand::SamplePorts + | BackendCommand::TerminateProcess { .. } => {} } } }); @@ -173,24 +177,27 @@ mod tests { let (events_tx, events_rx) = std::sync::mpsc::channel(); let handle = tokio::runtime::Handle::current(); let session = Session::serial(slave_name, 0); - let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, events_tx); + let backend_events = GuardedBackendEventSender::new(events_tx); + let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, backend_events); // Wait for the Status event - let status_event = events_rx.recv_timeout(std::time::Duration::from_secs(2)); - assert!(status_event.is_ok(), "Failed to receive Status event"); - if let Ok(BackendEvent::Status { tab_id, .. }) = status_event { - assert_eq!(tab_id, "test-tab"); - } else { - panic!("Expected Status event, got: {:?}", status_event); + let status_event = events_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("Failed to receive Status event") + .into_current(); + match status_event { + Some(BackendEvent::Status { tab_id, .. }) => assert_eq!(tab_id, "test-tab"), + event => panic!("Expected Status event, got: {event:?}"), } // Wait for the Connected event - let connected_event = events_rx.recv_timeout(std::time::Duration::from_secs(2)); - assert!(connected_event.is_ok(), "Failed to receive Connected event"); - if let Ok(BackendEvent::Connected { tab_id }) = connected_event { - assert_eq!(tab_id, "test-tab"); - } else { - panic!("Expected Connected event, got: {:?}", connected_event); + let connected_event = events_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("Failed to receive Connected event") + .into_current(); + match connected_event { + Some(BackendEvent::Connected { tab_id }) => assert_eq!(tab_id, "test-tab"), + event => panic!("Expected Connected event, got: {event:?}"), } // 3. Test Reading: Write to PTY master, verify serial backend outputs it to UI @@ -198,13 +205,16 @@ mod tests { master_writer.write_all(b"hello serial simulator").unwrap(); master_writer.flush().unwrap(); - let output_event = events_rx.recv_timeout(std::time::Duration::from_secs(2)); - assert!(output_event.is_ok(), "Failed to receive Output event"); - if let Ok(BackendEvent::Output { tab_id, bytes }) = output_event { - assert_eq!(tab_id, "test-tab"); - assert_eq!(bytes, b"hello serial simulator"); - } else { - panic!("Expected Output event"); + let output_event = events_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("Failed to receive Output event") + .into_current(); + match output_event { + Some(BackendEvent::Output { tab_id, bytes }) => { + assert_eq!(tab_id, "test-tab"); + assert_eq!(bytes, b"hello serial simulator"); + } + event => panic!("Expected Output event, got: {event:?}"), } // 4. Test Writing: Send BackendCommand::Input to backend, verify PTY master reads it diff --git a/src/backend/ssh.rs b/src/backend/ssh.rs index 76510d7..3d04c80 100644 --- a/src/backend/ssh.rs +++ b/src/backend/ssh.rs @@ -5,6 +5,7 @@ use std::{ use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; +use base64::Engine as _; use directories::BaseDirs; use russh::{ ChannelMsg, Disconnect, @@ -21,8 +22,11 @@ use crate::{ session_has_explicit_key, }, }, - system::{SystemSnapshot, remote_snapshot_from_kv}, - terminal::{BackendCommand, BackendEvent, BackendTx}, + system::{ + RemotePort, RemoteProcess, SystemSnapshot, remote_ports_from_probe, + remote_processes_from_ps, remote_snapshot_from_kv, + }, + terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender}, }; pub fn spawn_ssh_terminal( @@ -31,7 +35,7 @@ pub fn spawn_ssh_terminal( session: Session, cols: u16, rows: u16, - events: std::sync::mpsc::Sender, + events: GuardedBackendEventSender, ) -> BackendTx { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); let task_tab = tab_id.clone(); @@ -58,30 +62,213 @@ pub fn spawn_ssh_terminal( async fn sample_remote_system_with_handle( handle: Arc>>, ) -> Result { + let unix_result = async { + let output = execute_remote_command_with_handle( + handle.clone(), + REMOTE_SYSTEM_PROBE, + "Unix remote metrics probe", + ) + .await?; + remote_snapshot_from_kv(&output).context("parse Unix remote metrics probe") + } + .await; + + match unix_result { + Ok(snapshot) => Ok(snapshot), + Err(unix_error) => { + let command = powershell_encoded_command(REMOTE_WINDOWS_SYSTEM_PROBE); + let output = execute_remote_command_with_handle( + handle, + &command, + "Windows remote metrics probe", + ) + .await + .with_context(|| format!("Unix probe failed first: {unix_error:#}"))?; + remote_snapshot_from_kv(&output) + .context("parse Windows remote metrics probe") + .with_context(|| format!("Unix probe failed first: {unix_error:#}")) + } + } +} + +async fn execute_remote_command_with_handle( + handle: Arc>>, + command: &str, + operation: &str, +) -> Result { let mut channel = handle .lock() .await .channel_open_session() .await - .context("open metrics session")?; + .with_context(|| format!("open {operation} session"))?; channel - .exec(true, REMOTE_SYSTEM_PROBE) + .exec(true, command) .await - .context("exec remote metrics probe")?; + .with_context(|| format!("execute {operation}"))?; - let mut stdout = Vec::new(); - while let Some(msg) = channel.wait().await { - match msg { - ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => { - stdout.extend_from_slice(&data); + let mut output = Vec::new(); + let mut exit_status = None; + let wait_result = tokio::time::timeout(std::time::Duration::from_secs(20), async { + while let Some(msg) = channel.wait().await { + match msg { + ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => { + output.extend_from_slice(&data); + } + ChannelMsg::ExitStatus { + exit_status: status, + } => exit_status = Some(status), + ChannelMsg::Close => break, + _ => {} } - ChannelMsg::Close => break, - _ => {} } + }) + .await; + + if wait_result.is_err() { + return Err(anyhow!("{operation} timed out after 20 seconds")); } - let output = String::from_utf8_lossy(&stdout); - remote_snapshot_from_kv(&output) + let output = String::from_utf8_lossy(&output).trim().to_string(); + if exit_status.is_none_or(|status| status != 0) { + let detail = if output.is_empty() { + exit_status.map_or_else( + || "remote command closed without an exit status".to_string(), + |status| format!("exit status {status}"), + ) + } else { + output + }; + return Err(anyhow!("{operation} failed: {detail}")); + } + Ok(output) +} + +async fn sample_remote_processes_with_handle( + handle: Arc>>, +) -> Result> { + let unix_result = async { + let output = execute_remote_command_with_handle( + handle.clone(), + REMOTE_PROCESS_PROBE, + "Unix remote process probe", + ) + .await?; + parse_remote_process_probe(&output, "Unix remote process probe") + } + .await; + + match unix_result { + Ok(processes) => Ok(processes), + Err(unix_error) => { + let command = powershell_encoded_command(REMOTE_WINDOWS_PROCESS_PROBE); + let output = execute_remote_command_with_handle( + handle, + &command, + "Windows remote process probe", + ) + .await + .with_context(|| format!("Unix probe failed first: {unix_error:#}"))?; + parse_remote_process_probe(&output, "Windows remote process probe") + .with_context(|| format!("Unix probe failed first: {unix_error:#}")) + } + } +} + +fn parse_remote_process_probe(output: &str, operation: &str) -> Result> { + let processes = remote_processes_from_ps(output); + if !processes.is_empty() { + return Ok(processes); + } + + let detail = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("empty output"); + Err(anyhow!( + "{operation} returned no parseable process rows: {detail}" + )) +} + +async fn sample_remote_ports_with_handle( + handle: Arc>>, +) -> Result> { + let unix_result = async { + let output = execute_remote_command_with_handle( + handle.clone(), + REMOTE_PORT_PROBE, + "Unix remote port probe", + ) + .await?; + parse_remote_port_probe(&output, "Unix remote port probe") + } + .await; + + match unix_result { + Ok(ports) => Ok(ports), + Err(unix_error) => { + let command = powershell_encoded_command(REMOTE_WINDOWS_PORT_PROBE); + let output = + execute_remote_command_with_handle(handle, &command, "Windows remote port probe") + .await + .with_context(|| format!("Unix probe failed first: {unix_error:#}"))?; + parse_remote_port_probe(&output, "Windows remote port probe") + .with_context(|| format!("Unix probe failed first: {unix_error:#}")) + } + } +} + +fn parse_remote_port_probe(output: &str, operation: &str) -> Result> { + let ports = remote_ports_from_probe(output); + if !ports.is_empty() { + return Ok(ports); + } + if output.trim().is_empty() { + return Ok(Vec::new()); + } + + let detail = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("empty output"); + Err(anyhow!( + "{operation} returned no parseable port rows: {detail}" + )) +} + +async fn terminate_remote_process_with_handle( + handle: Arc>>, + pid: u32, +) -> Result<()> { + if pid <= 1 { + return Err(anyhow!("refusing to terminate protected PID {pid}")); + } + if let Err(unix_error) = execute_remote_command_with_handle( + handle.clone(), + &format!("kill -TERM {pid}"), + "terminate Unix remote process", + ) + .await + { + let command = powershell_encoded_command(&format!( + "$ErrorActionPreference = 'Stop'; Stop-Process -Id {pid} -ErrorAction Stop" + )); + execute_remote_command_with_handle(handle, &command, "terminate Windows remote process") + .await + .with_context(|| format!("Unix termination failed first: {unix_error:#}"))?; + } + Ok(()) +} + +fn powershell_encoded_command(script: &str) -> String { + let utf16_le = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + let encoded = base64::engine::general_purpose::STANDARD.encode(utf16_le); + format!("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}") } async fn run_ssh( @@ -90,7 +277,7 @@ async fn run_ssh( cols: u16, rows: u16, mut commands: mpsc::UnboundedReceiver, - events: std::sync::mpsc::Sender, + events: GuardedBackendEventSender, ) -> Result<()> { let _ = events.send(BackendEvent::Status { tab_id: tab_id.clone(), @@ -162,6 +349,82 @@ async fn run_ssh( } }); } + Some(BackendCommand::SampleProcesses) => { + let handle_clone = handle.clone(); + let tab_id_clone = tab_id.clone(); + let events_clone = events.clone(); + tokio::spawn(async move { + match sample_remote_processes_with_handle(handle_clone).await { + Ok(processes) => { + let _ = events_clone.send(BackendEvent::RemoteProcesses { + tab_id: tab_id_clone, + processes, + }); + } + Err(err) => { + let _ = events_clone.send( + BackendEvent::RemoteProcessesUnavailable { + tab_id: tab_id_clone, + reason: format!( + "remote process list unavailable: {err:#}" + ), + }, + ); + } + } + }); + } + Some(BackendCommand::SamplePorts) => { + let handle_clone = handle.clone(); + let tab_id_clone = tab_id.clone(); + let events_clone = events.clone(); + tokio::spawn(async move { + match sample_remote_ports_with_handle(handle_clone).await { + Ok(ports) => { + let _ = events_clone.send(BackendEvent::RemotePorts { + tab_id: tab_id_clone, + ports, + }); + } + Err(err) => { + let _ = events_clone.send( + BackendEvent::RemotePortsUnavailable { + tab_id: tab_id_clone, + reason: format!( + "remote port list unavailable: {err:#}" + ), + }, + ); + } + } + }); + } + Some(BackendCommand::TerminateProcess { pid }) => { + let handle_clone = handle.clone(); + let tab_id_clone = tab_id.clone(); + let events_clone = events.clone(); + tokio::spawn(async move { + match terminate_remote_process_with_handle(handle_clone, pid).await { + Ok(()) => { + let _ = events_clone.send( + BackendEvent::RemoteProcessTerminated { + tab_id: tab_id_clone, + pid, + }, + ); + } + Err(err) => { + let _ = events_clone.send( + BackendEvent::RemoteProcessTerminateFailed { + tab_id: tab_id_clone, + pid, + reason: format!("{err:#}"), + }, + ); + } + } + }); + } Some(BackendCommand::Close) | None => { tracing::info!("[ssh] local client closed the session for tab {}", tab_id); let _ = channel.eof().await; @@ -222,7 +485,7 @@ async fn run_ssh( async fn connect_and_authenticate( tab_id: &str, session: &Session, - events: &std::sync::mpsc::Sender, + events: &GuardedBackendEventSender, ) -> Result> { let config = Arc::new(client::Config { inactivity_timeout: None, @@ -306,7 +569,7 @@ async fn connect_and_authenticate( let passphrase = session.passphrase.trim(); let passphrase = (!passphrase.is_empty()).then_some(passphrase); - let success = if has_explicit_key { + if has_explicit_key { let keypair = load_session_private_key(session)?; let algorithm = format!("{:?}", keypair.algorithm()); let _ = events.send(BackendEvent::Status { @@ -356,8 +619,7 @@ async fn connect_and_authenticate( )); } success - }; - success + } } AuthMethod::Config => { // SSH Config auth: try the identity file from config, or default keys @@ -610,15 +872,229 @@ EOF exit 0 fi -echo "CPU_PERCENT=0.00" -echo "MEM_TOTAL=0" -echo "MEM_USED=0" -echo "SWAP_TOTAL=0" -echo "SWAP_USED=0" -echo "NET_RX=0" -echo "NET_TX=0" +echo "unsupported Unix remote operating system: $os" >&2 +exit 2 '"#; +const REMOTE_PROCESS_PROBE: &str = r#"sh -lc ' +os=$(uname -s 2>/dev/null || echo unknown) + +if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then + before=$(mktemp "${TMPDIR:-/tmp}/ashell-process-before.XXXXXX") || exit 1 + after=$(mktemp "${TMPDIR:-/tmp}/ashell-process-after.XXXXXX") || { rm -f "$before"; exit 1; } + trap '"'"'rm -f "$before" "$after"'"'"' EXIT HUP INT TERM + hz=$(getconf CLK_TCK 2>/dev/null || echo 100) + page_size=$(getconf PAGESIZE 2>/dev/null || echo 4096) + + capture_processes() { + for process_stat in /proc/[0-9]*/stat; do + [ -r "$process_stat" ] || continue + process_pid=${process_stat#/proc/} + process_pid=${process_pid%/stat} + process_line=$(cat "$process_stat" 2>/dev/null) || continue + process_rest=${process_line##*) } + set -- $process_rest + [ "$#" -ge 22 ] || continue + process_ticks=$((${12} + ${13})) + process_start=${20} + process_rss_pages=${22} + [ "$process_rss_pages" -ge 0 ] 2>/dev/null || process_rss_pages=0 + process_memory=$((process_rss_pages * page_size)) + process_user=$(stat -c %U "/proc/$process_pid" 2>/dev/null || echo "-") + process_command=$(tr '"'"'\000\t\r\n'"'"' '"'"' '"'"' < "/proc/$process_pid/cmdline" 2>/dev/null) + if [ -z "$process_command" ]; then + process_command=$(tr '"'"'\t\r\n'"'"' '"'"' '"'"' < "/proc/$process_pid/comm" 2>/dev/null) + fi + [ -n "$process_command" ] || process_command="[$process_pid]" + printf '"'"'%s\t%s\t%s\t%s\t%s\t%s\n'"'"' "$process_pid" "$process_user" "$process_ticks" "$process_start" "$process_memory" "$process_command" + done + } + + capture_processes > "$before" + sleep 1 + capture_processes > "$after" + awk -F '"'"'\t'"'"' -v hz="$hz" '"'"' + NR == FNR { ticks[$1] = $3; starts[$1] = $4; next } + { + delta = ($1 in ticks && starts[$1] == $4) ? ($3 - ticks[$1]) : 0; + cpu = (delta > 0 && hz > 0) ? delta * 100 / hz : 0; + printf "PROCESS\t%s\t%s\t%.2f\t%s\t%s\n", $1, $2, cpu, $5, $6; + } + '"'"' "$before" "$after" + exit 0 +fi + +if [ "$os" = "Darwin" ]; then + top_output=$(mktemp "${TMPDIR:-/tmp}/ashell-process-top.XXXXXX") || exit 1 + trap '"'"'rm -f "$top_output"'"'"' EXIT HUP INT TERM + LC_ALL=C top -l 2 -s 1 -n 10000 -stats pid,cpu > "$top_output" 2>/dev/null || exit 1 + LC_ALL=C ps -axo pid=,user=,rss=,command= 2>/dev/null | awk -v top_output="$top_output" '"'"' + BEGIN { + sample = 0; + while ((getline line < top_output) > 0) { + count = split(line, fields, /[[:space:]]+/); + start = fields[1] == "" ? 2 : 1; + if (fields[start] == "PID") { sample++; continue; } + if (sample == 2 && fields[start] ~ /^[0-9]+$/) { + value = fields[start + 1]; + gsub(/%/, "", value); + cpu[fields[start]] = value + 0; + } + } + close(top_output); + } + { + pid = $1; user = $2; memory = $3 * 1024; + $1 = $2 = $3 = ""; + sub(/^[[:space:]]+/, ""); + printf "PROCESS\t%s\t%s\t%.2f\t%.0f\t%s\n", pid, user, cpu[pid] + 0, memory, $0; + } + '"'"' + exit 0 +fi + +echo "unsupported Unix remote operating system: $os" >&2 +exit 2 +'"#; + +const REMOTE_PORT_PROBE: &str = r#"sh -lc ' +if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '"'"' + NR > 1 && $2 ~ /^[0-9]+$/ { + endpoint = $9; + state = $10; + gsub(/[()]/, "", state); + if (endpoint !~ /:[0-9]+$/) next; + port = endpoint; + sub(/^.*:/, "", port); + address = endpoint; + sub(/:[^:]*$/, "", address); + if (address == "") address = "*"; + if (state == "") state = "LISTEN"; + printf "PORT\tTCP\t%s\t%s\t%s\t%s\t%s\n", address, port, state, $2, $1; + } + '"'"' + lsof -nP -iUDP 2>/dev/null | awk '"'"' + NR > 1 && $2 ~ /^[0-9]+$/ { + endpoint = $9; + if (endpoint !~ /:[0-9]+$/) next; + port = endpoint; + sub(/^.*:/, "", port); + address = endpoint; + sub(/:[^:]*$/, "", address); + if (address == "") address = "*"; + printf "PORT\tUDP\t%s\t%s\tUNCONN\t%s\t%s\n", address, port, $2, $1; + } + '"'"' + exit 0 +fi + +if command -v ss >/dev/null 2>&1; then + ss -H -lntup 2>/dev/null | awk '"'"' + NF >= 5 { + protocol = $1; + state = $2; + endpoint = $5; + if (endpoint !~ /:[0-9]+$/) next; + port = endpoint; + sub(/^.*:/, "", port); + address = endpoint; + sub(/:[^:]*$/, "", address); + pid = "-"; + process = "-"; + for (i = 6; i <= NF; i++) { + token = $i; + if (token ~ /users:/) { + name = token; + sub(/^.*\(\("/, "", name); + sub(/".*$/, "", name); + if (name != "") process = name; + if (match(token, /pid=[0-9]+/)) { + pid = substr(token, RSTART + 4, RLENGTH - 4); + } + } + } + printf "PORT\t%s\t%s\t%s\t%s\t%s\t%s\n", protocol, address, port, state, pid, process; + } + '"'"' + exit 0 +fi + +if command -v netstat >/dev/null 2>&1; then + netstat -an 2>/dev/null | awk '"'"' + NR > 1 && NF >= 4 { + protocol = $1; + endpoint = $4; + state = protocol ~ /^udp/i ? "UNCONN" : "LISTEN"; + if (endpoint !~ /:[0-9]+$/ && endpoint !~ /\.[0-9]+$/) next; + port = endpoint; + sub(/^.*[:.]/, "", port); + address = endpoint; + sub(/[:.][^:.]*$/, "", address); + if (address == "") address = "*"; + if (protocol !~ /^udp/i && $NF != "LISTEN" && $NF != "LISTENING") next; + printf "PORT\t%s\t%s\t%s\t%s\t-\t-\n", protocol, address, port, state; + } + '"'"' + exit 0 +fi + +echo "no supported remote port utility found" >&2 +exit 2 +'"#; + +const REMOTE_WINDOWS_SYSTEM_PROBE: &str = r#"$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average +$os = Get-CimInstance Win32_OperatingSystem +$memTotal = [uint64]$os.TotalVisibleMemorySize * 1024 +$memFree = [uint64]$os.FreePhysicalMemory * 1024 +$swapTotal = [uint64]$os.TotalVirtualMemorySize * 1024 - $memTotal +$swapFree = [uint64]$os.FreeVirtualMemory * 1024 - $memFree +$network = Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface +$netRx = ($network | Measure-Object -Property BytesReceivedPersec -Sum).Sum +$netTx = ($network | Measure-Object -Property BytesSentPersec -Sum).Sum +$cpuText = [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:F2}", [double]$cpu) +Write-Output ("CPU_PERCENT={0}" -f $cpuText) +Write-Output ("MEM_TOTAL={0}" -f $memTotal) +Write-Output ("MEM_USED={0}" -f ($memTotal - $memFree)) +Write-Output ("SWAP_TOTAL={0}" -f ([Math]::Max(0, $swapTotal))) +Write-Output ("SWAP_USED={0}" -f ([Math]::Max(0, $swapTotal - $swapFree))) +Write-Output ("NET_RX={0}" -f ([uint64]$netRx)) +Write-Output ("NET_TX={0}" -f ([uint64]$netTx)) +Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object { + Write-Output ("DISK={0}`t{1}`t{2}" -f $_.DeviceID, [uint64]$_.FreeSpace, [uint64]$_.Size) +}"#; + +const REMOTE_WINDOWS_PROCESS_PROBE: &str = r#"$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$before = @{} +Get-Process | ForEach-Object { if ($null -ne $_.CPU) { $before[$_.Id] = [double]$_.CPU } } +Start-Sleep -Seconds 1 +Get-Process | ForEach-Object { + $previous = $before[$_.Id] + $cpu = if ($null -ne $previous -and $null -ne $_.CPU) { [Math]::Max(0, ([double]$_.CPU - $previous) * 100) } else { 0 } + $command = $_.ProcessName.Replace("`t", " ").Replace("`r", " ").Replace("`n", " ") + $cpuText = [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:F2}", $cpu) + Write-Output ("PROCESS`t{0}`t-`t{1}`t{2}`t{3}" -f $_.Id, $cpuText, [uint64]$_.WorkingSet64, $command) +}"#; + +const REMOTE_WINDOWS_PORT_PROBE: &str = r#"$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$tab = [char]9 +$processNames = @{} +Get-Process | ForEach-Object { $processNames[[int]$_.Id] = $_.ProcessName } +@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue) | ForEach-Object { + $ownerPid = [int]$_.OwningProcess + $processName = if ($processNames.ContainsKey($ownerPid)) { $processNames[$ownerPid] } else { '-' } + Write-Output ("PORT{0}TCP{0}{1}{0}{2}{0}LISTEN{0}{3}{0}{4}" -f $tab, $_.LocalAddress, $_.LocalPort, $ownerPid, $processName) +} +@(Get-NetUDPEndpoint -ErrorAction SilentlyContinue) | ForEach-Object { + $ownerPid = [int]$_.OwningProcess + $processName = if ($processNames.ContainsKey($ownerPid)) { $processNames[$ownerPid] } else { '-' } + Write-Output ("PORT{0}UDP{0}{1}{0}{2}{0}UNCONN{0}{3}{0}{4}" -f $tab, $_.LocalAddress, $_.LocalPort, $ownerPid, $processName) +}"#; + #[derive(Clone)] struct ClientHandler; diff --git a/src/session/config.rs b/src/session/config.rs index 186d29c..00f2c33 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -1,4 +1,10 @@ -use std::{fs, path::PathBuf, sync::OnceLock}; +use std::{ + collections::{HashMap, HashSet}, + fs, + io::Write as _, + path::{Path, PathBuf}, + sync::{Arc, Mutex, OnceLock}, +}; use anyhow::{Context, Result}; use argon2::Argon2; @@ -12,6 +18,8 @@ use rand::{RngCore, rngs::OsRng}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::text_encoding::TextEncoding; + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum AuthMethod { @@ -60,6 +68,8 @@ pub struct Session { pub protocol: String, #[serde(default = "default_baud_rate")] pub baud_rate: u32, + #[serde(default)] + pub terminal_encoding: TextEncoding, } impl Session { @@ -84,6 +94,7 @@ impl Session { proxy_password: String::new(), protocol: "ssh".to_string(), baud_rate: 115200, + terminal_encoding: TextEncoding::Utf8, } } @@ -115,6 +126,7 @@ impl Session { proxy_password: String::new(), protocol: "ssh".to_string(), baud_rate: 115200, + terminal_encoding: TextEncoding::Utf8, } } @@ -139,6 +151,7 @@ impl Session { proxy_password: String::new(), protocol: "serial".to_string(), baud_rate, + terminal_encoding: TextEncoding::Utf8, } } } @@ -166,6 +179,61 @@ pub enum SavedWindowBounds { }, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SavedPaneLayout { + Single { + tab_id: String, + }, + Horizontal { + children: Vec, + ratio: f32, + }, + Vertical { + children: Vec, + ratio: f32, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SavedTerminalTab { + Local { + id: String, + #[serde(default)] + cwd: Option, + #[serde(default)] + terminal_encoding: TextEncoding, + }, + Ssh { + id: String, + session: Session, + }, + Serial { + id: String, + session: Session, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedTabGroup { + pub id: String, + pub title: String, + pub pane_root: SavedPaneLayout, + #[serde(default)] + pub tabs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedTabsState { + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub active_group: Option, + #[serde(default)] + pub active_tab: Option, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum TitleBarStyle { @@ -214,6 +282,15 @@ pub struct ConfigFile { pub cursor_style: CursorStyle, #[serde(default)] pub sessions: Vec, + /// Shell commands recorded for each SSH session ID. + #[serde(default)] + pub command_history: HashMap>, + #[serde(default)] + pub command_history_revision: u64, + #[serde(default)] + pub remember_tabs: bool, + #[serde(default)] + pub saved_tabs: Option, #[serde(default)] pub window_bounds: Option, #[serde(default)] @@ -221,6 +298,12 @@ pub struct ConfigFile { #[serde(default)] pub body_panels: Option>, #[serde(default)] + pub sftp_tree_panels: Option>, + #[serde(default)] + pub sftp_file_columns: Option>, + #[serde(default)] + pub sftp_file_columns_customized: bool, + #[serde(default)] pub transfers: Vec, #[serde(default)] pub show_hidden_files: bool, @@ -316,6 +399,35 @@ fn default_terminal_font_family() -> String { "Maple Mono NF CN".to_string() } +const MAX_COMMAND_HISTORY: usize = 200; + +fn normalize_command_history_entries(history: &mut Vec) -> bool { + let mut seen = HashSet::new(); + let mut changed = false; + let mut normalized = history + .drain(..) + .rev() + .filter_map(|command| { + let trimmed = command.trim(); + changed |= trimmed.len() != command.len(); + let command = trimmed.to_string(); + if command.is_empty() || !seen.insert(command.clone()) { + changed = true; + None + } else { + Some(command) + } + }) + .collect::>(); + normalized.reverse(); + if normalized.len() > MAX_COMMAND_HISTORY { + changed = true; + normalized.drain(..normalized.len() - MAX_COMMAND_HISTORY); + } + *history = normalized; + changed +} + impl Default for ConfigFile { fn default() -> Self { Self { @@ -333,9 +445,16 @@ impl Default for ConfigFile { title_bar_style: TitleBarStyle::default(), cursor_style: CursorStyle::default(), sessions: Vec::new(), + command_history: HashMap::new(), + command_history_revision: 0, + remember_tabs: false, + saved_tabs: None, window_bounds: None, workspace_panels: None, body_panels: None, + sftp_tree_panels: None, + sftp_file_columns: None, + sftp_file_columns_customized: false, transfers: Vec::new(), show_hidden_files: false, lock_layout: false, @@ -368,6 +487,80 @@ impl Default for ConfigFile { pub struct ConfigStore { pub(crate) path: PathBuf, pub(crate) cache: ConfigFile, + write_lock: Arc>, +} + +fn config_backup_path(path: &Path) -> PathBuf { + path.with_extension("json.bak") +} + +fn decode_config_bytes(raw_bytes: &[u8], hardware_uuid: &str) -> Result { + match decrypt_config(raw_bytes, hardware_uuid) { + Ok(cache) => Ok(cache), + Err(decrypt_err) => serde_json::from_slice::(raw_bytes).map_err(|json_err| { + anyhow::anyhow!( + "decrypt failed: {decrypt_err:#}; plain JSON parsing failed: {json_err:#}" + ) + }), + } +} + +fn persist_config_bytes(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path + .parent() + .context("configuration path has no parent directory")?; + let mut temporary = tempfile::Builder::new() + .prefix(".ashell-config-") + .suffix(".tmp") + .tempfile_in(parent) + .with_context(|| format!("failed to create temporary config in {}", parent.display()))?; + temporary + .write_all(contents) + .with_context(|| format!("failed to write temporary config for {}", path.display()))?; + temporary + .as_file() + .sync_all() + .with_context(|| format!("failed to sync temporary config for {}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + let mut permissions = temporary + .as_file() + .metadata() + .with_context(|| format!("failed to inspect temporary config for {}", path.display()))? + .permissions(); + permissions.set_mode(0o600); + temporary + .as_file() + .set_permissions(permissions) + .with_context(|| { + format!("failed to protect temporary config for {}", path.display()) + })?; + } + + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("failed to replace {} atomically", path.display()))?; + + #[cfg(unix)] + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .with_context(|| format!("failed to sync config directory {}", parent.display()))?; + + Ok(()) +} + +fn write_config_bytes(path: &Path, contents: &[u8]) -> Result<()> { + if path.exists() { + let previous = fs::read(path) + .with_context(|| format!("failed to read previous config {}", path.display()))?; + persist_config_bytes(&config_backup_path(path), &previous) + .with_context(|| format!("failed to back up config {}", path.display()))?; + } + persist_config_bytes(path, contents) } impl ConfigStore { @@ -395,28 +588,68 @@ impl ConfigStore { let raw_bytes = fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; let hardware_uuid = get_hardware_uuid(); - match decrypt_config(&raw_bytes, &hardware_uuid) { - Ok(cache) => cache, - Err(decrypt_err) => { - // Fallback to plain text JSON if decryption/parsing failed - match serde_json::from_slice::(&raw_bytes) { - Ok(cache) => cache, - Err(json_err) => { - let backup_path = path.with_extension("json.bak"); - if let Err(backup_err) = fs::write(&backup_path, &raw_bytes) { - tracing::warn!( - "failed to parse config {} (decrypt err: {decrypt_err:#}, json err: {json_err:#}); backup to {} also failed: {backup_err:#}", + match decode_config_bytes(&raw_bytes, &hardware_uuid) { + Ok(mut cache) => { + let backup_path = config_backup_path(&path); + if let Ok(backup_bytes) = fs::read(&backup_path) + && let Ok(backup_cache) = decode_config_bytes(&backup_bytes, &hardware_uuid) + { + let backup_history_is_newer = backup_cache.command_history_revision + > cache.command_history_revision + || (backup_cache.command_history_revision == 0 + && cache.command_history_revision == 0 + && cache.command_history.is_empty() + && !backup_cache.command_history.is_empty()); + if backup_history_is_newer { + cache.command_history = backup_cache.command_history; + cache.command_history_revision = + backup_cache.command_history_revision.max(1); + let encrypted_bytes = encrypt_config(&cache, &hardware_uuid)?; + persist_config_bytes(&path, &encrypted_bytes).with_context(|| { + format!( + "failed to restore command history in {} from {}", path.display(), - backup_path.display(), - ); - } else { - tracing::warn!( - "failed to parse config {} (decrypt err: {decrypt_err:#}, json err: {json_err:#}); backed up the original to {} and loaded defaults", + backup_path.display() + ) + })?; + tracing::warn!( + "restored newer command history in {} from {}", + path.display(), + backup_path.display(), + ); + } + } + cache + } + Err(primary_err) => { + let backup_path = config_backup_path(&path); + let backup_bytes = fs::read(&backup_path).with_context(|| { + format!("failed to read config backup {}", backup_path.display()) + }); + match backup_bytes.and_then(|backup_bytes| { + decode_config_bytes(&backup_bytes, &hardware_uuid) + .map(|cache| (backup_bytes, cache)) + }) { + Ok((backup_bytes, cache)) => { + tracing::warn!( + "failed to load config {}: {primary_err:#}; restored {}", + path.display(), + backup_path.display(), + ); + persist_config_bytes(&path, &backup_bytes).with_context(|| { + format!( + "failed to restore config {} from {}", path.display(), - backup_path.display(), - ); - } - ConfigFile::default() + backup_path.display() + ) + })?; + cache + } + Err(backup_err) => { + return Err(anyhow::anyhow!( + "failed to load config {}: {primary_err:#}; backup recovery failed: {backup_err:#}", + path.display() + )); } } } @@ -428,7 +661,23 @@ impl ConfigStore { if cache.sync_device_id.is_empty() { cache.sync_device_id = Uuid::new_v4().to_string(); } - Ok(Self { path, cache }) + let mut history_changed = false; + for history in cache.command_history.values_mut() { + history_changed |= normalize_command_history_entries(history); + } + let previous_history_count = cache.command_history.len(); + cache + .command_history + .retain(|_, history| !history.is_empty()); + history_changed |= cache.command_history.len() != previous_history_count; + if history_changed { + cache.command_history_revision = cache.command_history_revision.saturating_add(1); + } + Ok(Self { + path, + cache, + write_lock: Arc::new(Mutex::new(())), + }) } pub fn in_memory() -> Self { @@ -439,6 +688,7 @@ impl ConfigStore { Self { path: PathBuf::new(), cache, + write_lock: Arc::new(Mutex::new(())), } } @@ -459,6 +709,92 @@ impl ConfigStore { self.cache.sessions = sessions; } + /// Return persisted command history for all SSH sessions, newest first per session. + pub fn all_command_history(&self) -> Vec<(String, usize, String)> { + let mut session_ids = self + .cache + .command_history + .keys() + .cloned() + .collect::>(); + session_ids.sort(); + + let mut entries = Vec::new(); + for session_id in session_ids { + if let Some(history) = self.cache.command_history.get(&session_id) { + entries.extend( + history + .iter() + .enumerate() + .rev() + .map(|(index, command)| (session_id.clone(), index, command.clone())), + ); + } + } + entries + } + + pub fn add_command_history(&mut self, session_id: &str, command: String) -> bool { + let command = command.trim().to_string(); + if command.is_empty() { + return false; + } + + let history = self + .cache + .command_history + .entry(session_id.to_string()) + .or_default(); + let was_latest = history.last().is_some_and(|previous| previous == &command); + let previous_len = history.len(); + history.retain(|previous| previous != &command); + history.push(command); + if history.len() > MAX_COMMAND_HISTORY { + let excess = history.len() - MAX_COMMAND_HISTORY; + history.drain(..excess); + } + let changed = !was_latest || history.len() != previous_len; + if changed { + self.cache.command_history_revision = + self.cache.command_history_revision.saturating_add(1); + } + changed + } + + pub fn normalize_command_history(&mut self) { + let mut changed = false; + for history in self.cache.command_history.values_mut() { + changed |= normalize_command_history_entries(history); + } + let previous_history_count = self.cache.command_history.len(); + self.cache + .command_history + .retain(|_, history| !history.is_empty()); + changed |= self.cache.command_history.len() != previous_history_count; + if changed { + self.cache.command_history_revision = + self.cache.command_history_revision.saturating_add(1); + } + } + + pub fn remove_command_history(&mut self, session_id: &str, index: usize) -> bool { + let became_empty = { + let Some(history) = self.cache.command_history.get_mut(session_id) else { + return false; + }; + if index >= history.len() { + return false; + } + history.remove(index); + history.is_empty() + }; + if became_empty { + self.cache.command_history.remove(session_id); + } + self.cache.command_history_revision = self.cache.command_history_revision.saturating_add(1); + true + } + pub fn sync_endpoint(&self) -> &str { &self.cache.sync_endpoint } @@ -536,10 +872,6 @@ impl ConfigStore { self.cache.sync_etag_backend = self.sync_backend().to_string(); } - pub fn tmp_dir(&self) -> Option { - self.path.parent().map(|p| p.join("tmp")) - } - pub fn follow_system_theme(&self) -> bool { self.cache.follow_system_theme } @@ -615,6 +947,25 @@ impl ConfigStore { self.cache.window_bounds.as_ref() } + pub fn remember_tabs(&self) -> bool { + self.cache.remember_tabs + } + + pub fn set_remember_tabs(&mut self, remember_tabs: bool) { + self.cache.remember_tabs = remember_tabs; + if !remember_tabs { + self.cache.saved_tabs = None; + } + } + + pub fn saved_tabs(&self) -> Option<&SavedTabsState> { + self.cache.saved_tabs.as_ref() + } + + pub fn set_saved_tabs(&mut self, saved_tabs: Option) { + self.cache.saved_tabs = saved_tabs; + } + pub fn workspace_panels(&self) -> Option<&Vec> { self.cache.workspace_panels.as_ref() } @@ -624,6 +975,18 @@ impl ConfigStore { self.cache.body_panels.as_ref() } + pub fn sftp_tree_panels(&self) -> Option<&Vec> { + self.cache.sftp_tree_panels.as_ref() + } + + pub fn sftp_file_columns(&self) -> Option<&Vec> { + self.cache.sftp_file_columns.as_ref() + } + + pub fn sftp_file_columns_customized(&self) -> bool { + self.cache.sftp_file_columns_customized + } + pub fn transfers(&self) -> Vec { self.cache.transfers.clone() } @@ -646,6 +1009,18 @@ impl ConfigStore { self.cache.body_panels = body_panels; } + pub fn set_sftp_tree_panels(&mut self, panels: Option>) { + self.cache.sftp_tree_panels = panels; + } + + pub fn set_sftp_file_columns(&mut self, columns: Option>) { + self.cache.sftp_file_columns = columns; + } + + pub fn set_sftp_file_columns_customized(&mut self, customized: bool) { + self.cache.sftp_file_columns_customized = customized; + } + pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) { self.cache.terminal_font_size = terminal_font_size.max(10.0); } @@ -807,45 +1182,44 @@ impl ConfigStore { pub fn remove(&mut self, id: &str) { self.cache.sessions.retain(|s| s.id != id); + if self.cache.command_history.remove(id).is_some() { + self.cache.command_history_revision = + self.cache.command_history_revision.saturating_add(1); + } } pub fn save(&self) -> Result<()> { if self.path.as_os_str().is_empty() { return Ok(()); } + let _guard = self + .write_lock + .lock() + .map_err(|_| anyhow::anyhow!("configuration write lock is poisoned"))?; let hardware_uuid = get_hardware_uuid(); let encrypted_bytes = encrypt_config(&self.cache, &hardware_uuid)?; - fs::write(&self.path, encrypted_bytes) - .with_context(|| format!("failed to write {}", self.path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(mut perms) = fs::metadata(&self.path).map(|m| m.permissions()) { - perms.set_mode(0o600); - let _ = fs::set_permissions(&self.path, perms); - } - } - - Ok(()) + write_config_bytes(&self.path, &encrypted_bytes) } pub fn save_merged_preferences(&self, local_config: ConfigFile) -> Result<()> { if self.path.as_os_str().is_empty() { return Ok(()); } + let _guard = self + .write_lock + .lock() + .map_err(|_| anyhow::anyhow!("configuration write lock is poisoned"))?; let hardware_uuid = get_hardware_uuid(); let mut disk_config = if self.path.exists() { - if let Ok(raw_bytes) = fs::read(&self.path) { - match decrypt_config(&raw_bytes, &hardware_uuid) { - Ok(loaded) => loaded, - Err(_) => serde_json::from_slice::(&raw_bytes) - .unwrap_or_else(|_| self.cache.clone()), - } - } else { - self.cache.clone() - } + let raw_bytes = fs::read(&self.path) + .with_context(|| format!("failed to read {}", self.path.display()))?; + decode_config_bytes(&raw_bytes, &hardware_uuid).with_context(|| { + format!( + "refusing to overwrite unreadable config {}", + self.path.display() + ) + })? } else { self.cache.clone() }; @@ -864,29 +1238,34 @@ impl ConfigStore { disk_config.terminal_font_family = local_config.terminal_font_family; disk_config.title_bar_style = local_config.title_bar_style; disk_config.cursor_style = local_config.cursor_style; + if local_config.command_history_revision >= disk_config.command_history_revision { + disk_config.command_history = local_config.command_history; + disk_config.command_history_revision = local_config.command_history_revision; + } + disk_config.remember_tabs = local_config.remember_tabs; + disk_config.saved_tabs = local_config.saved_tabs; disk_config.window_bounds = local_config.window_bounds; disk_config.workspace_panels = local_config.workspace_panels; disk_config.body_panels = local_config.body_panels; + disk_config.sftp_tree_panels = local_config.sftp_tree_panels; + disk_config.sftp_file_columns = local_config.sftp_file_columns; + disk_config.sftp_file_columns_customized = local_config.sftp_file_columns_customized; disk_config.show_hidden_files = local_config.show_hidden_files; disk_config.lock_layout = local_config.lock_layout; disk_config.monitoring_position = local_config.monitoring_position; disk_config.sidebar_collapsed = local_config.sidebar_collapsed; disk_config.sftp_panel_minimized = local_config.sftp_panel_minimized; + disk_config.key_bindings = local_config.key_bindings; + disk_config.use_proxy = local_config.use_proxy; + disk_config.read_env_proxy = local_config.read_env_proxy; + disk_config.global_proxy_type = local_config.global_proxy_type; + disk_config.global_proxy_host = local_config.global_proxy_host; + disk_config.global_proxy_port = local_config.global_proxy_port; + disk_config.global_proxy_user = local_config.global_proxy_user; + disk_config.global_proxy_password = local_config.global_proxy_password; let encrypted_bytes = encrypt_config(&disk_config, &hardware_uuid)?; - fs::write(&self.path, encrypted_bytes) - .with_context(|| format!("failed to write {}", self.path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(mut perms) = fs::metadata(&self.path).map(|m| m.permissions()) { - perms.set_mode(0o600); - let _ = fs::set_permissions(&self.path, perms); - } - } - - Ok(()) + write_config_bytes(&self.path, &encrypted_bytes) } } @@ -1099,7 +1478,7 @@ pub fn get_hardware_uuid() -> String { #[cfg(target_os = "macos")] { if let Ok(output) = std::process::Command::new("ioreg") - .args(&["-rd1", "-c", "IOPlatformExpertDevice"]) + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) .output() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -1249,13 +1628,73 @@ mod tests { assert!(decrypt_config(&encrypted, "wrong-password").is_err()); } + #[test] + fn test_remember_tabs_defaults_to_disabled_for_older_configs() { + let config: ConfigFile = serde_json::from_str("{}").unwrap(); + + assert!(!config.remember_tabs); + assert!(config.saved_tabs.is_none()); + assert!(config.sftp_tree_panels.is_none()); + assert!(config.sftp_file_columns.is_none()); + assert!(!config.sftp_file_columns_customized); + } + + #[test] + fn command_history_keeps_only_the_latest_duplicate() { + let mut store = ConfigStore::in_memory(); + + assert!(store.add_command_history("session-1", "first".to_string())); + assert!(store.add_command_history("session-1", "second".to_string())); + assert!(store.add_command_history("session-1", "first".to_string())); + assert!(!store.add_command_history("session-1", "first".to_string())); + + assert_eq!( + store.cache.command_history.get("session-1"), + Some(&vec!["second".to_string(), "first".to_string()]) + ); + assert_eq!(store.cache.command_history_revision, 3); + } + + #[test] + fn test_saved_tabs_roundtrip() { + let config = ConfigFile { + remember_tabs: true, + saved_tabs: Some(SavedTabsState { + groups: vec![SavedTabGroup { + id: "group-1".to_string(), + title: "~".to_string(), + pane_root: SavedPaneLayout::Single { + tab_id: "tab-1".to_string(), + }, + tabs: vec![SavedTerminalTab::Local { + id: "tab-1".to_string(), + cwd: Some(PathBuf::from("/tmp")), + terminal_encoding: TextEncoding::Utf8, + }], + }], + active_group: Some("group-1".to_string()), + active_tab: Some("tab-1".to_string()), + }), + ..Default::default() + }; + + let json = serde_json::to_string(&config).unwrap(); + let restored: ConfigFile = serde_json::from_str(&json).unwrap(); + + assert!(restored.remember_tabs); + let restored_tabs = restored.saved_tabs.unwrap(); + assert_eq!(restored_tabs.groups.len(), 1); + assert_eq!(restored_tabs.active_tab.as_deref(), Some("tab-1")); + } + #[test] fn test_save_merged_preferences() { - let temp_dir = std::env::temp_dir(); - let path = temp_dir.join(format!("ashell-test-config-{}.json", Uuid::new_v4())); + let temp_dir = tempfile::tempdir().unwrap(); + let path = temp_dir.path().join("sessions.json"); let mut store = ConfigStore { path: path.clone(), cache: ConfigFile::default(), + write_lock: Arc::new(Mutex::new(())), }; let session = Session { @@ -1277,16 +1716,45 @@ mod tests { proxy_password: String::new(), protocol: "ssh".to_string(), baud_rate: 115200, + terminal_encoding: TextEncoding::Utf8, }; store.cache.sessions.push(session.clone()); store.save().unwrap(); - let mut local_config = ConfigFile::default(); - local_config.ui_font_size = 18.0; - local_config.terminal_font_size = 20.0; - local_config.show_hidden_files = true; + let mut local_config = ConfigFile { + ui_font_size: 18.0, + terminal_font_size: 20.0, + show_hidden_files: true, + sftp_file_columns_customized: true, + remember_tabs: true, + use_proxy: true, + read_env_proxy: false, + global_proxy_type: "http".to_string(), + global_proxy_host: "proxy.example.com".to_string(), + global_proxy_port: Some(8080), + global_proxy_user: "proxy-user".to_string(), + global_proxy_password: "proxy-password".to_string(), + command_history_revision: 2, + saved_tabs: Some(SavedTabsState { + groups: Vec::new(), + active_group: None, + active_tab: None, + }), + ..Default::default() + }; + local_config + .key_bindings + .insert("QuitApplication".to_string(), "cmd-q".to_string()); + local_config.command_history.insert( + "test-session-id".to_string(), + vec!["pwd".to_string(), "ls -la".to_string()], + ); + let mut stale_config = local_config.clone(); + stale_config.command_history.clear(); + stale_config.command_history_revision = 1; store.save_merged_preferences(local_config).unwrap(); + store.save_merged_preferences(stale_config).unwrap(); let loaded_bytes = fs::read(&path).unwrap(); let decrypted = decrypt_config(&loaded_bytes, &get_hardware_uuid()).unwrap(); @@ -1294,12 +1762,32 @@ mod tests { assert_eq!(decrypted.ui_font_size, 18.0); assert_eq!(decrypted.terminal_font_size, 20.0); assert!(decrypted.show_hidden_files); + assert!(decrypted.sftp_file_columns_customized); + assert!(decrypted.remember_tabs); + assert!(decrypted.saved_tabs.is_some()); + assert_eq!( + decrypted + .key_bindings + .get("QuitApplication") + .map(String::as_str), + Some("cmd-q") + ); + assert!(decrypted.use_proxy); + assert!(!decrypted.read_env_proxy); + assert_eq!(decrypted.global_proxy_type, "http"); + assert_eq!(decrypted.global_proxy_host, "proxy.example.com"); + assert_eq!(decrypted.global_proxy_port, Some(8080)); + assert_eq!(decrypted.global_proxy_user, "proxy-user"); + assert_eq!(decrypted.global_proxy_password, "proxy-password"); + assert_eq!( + decrypted.command_history.get("test-session-id"), + Some(&vec!["pwd".to_string(), "ls -la".to_string()]) + ); + assert_eq!(decrypted.command_history_revision, 2); assert_eq!(decrypted.sessions.len(), 1); assert_eq!(decrypted.sessions[0].name, "Test Session"); assert_eq!(decrypted.sessions[0].host, "1.2.3.4"); - - let _ = fs::remove_file(&path); } #[test] diff --git a/src/session/mod.rs b/src/session/mod.rs index 7774d54..581113b 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -2,6 +2,7 @@ pub mod config; pub mod ssh_config; pub mod ssh_keys; +use base64::Engine as _; use gpui::{ AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, SharedString, Window, px, @@ -10,28 +11,467 @@ use gpui_component::{Theme, WindowExt as _, input::InputState}; use rust_i18n::t; use uuid::Uuid; -use self::config::{AuthMethod, Session}; +use self::config::{ + AuthMethod, SavedPaneLayout, SavedTabGroup, SavedTabsState, SavedTerminalTab, Session, +}; use crate::{ Ashell, PaneLayout, SelectorEntry, TabGroup, app::constants::{DEFAULT_COLS, DEFAULT_ROWS}, backend::{local, ssh}, terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab}, + text_encoding::TextEncoding, }; +pub(crate) fn compact_local_path(path: &std::path::Path) -> String { + if let Some(base_dirs) = directories::BaseDirs::new() { + if let Some(relative_path) = relative_to_home(path, base_dirs.home_dir()) { + if relative_path.as_os_str().is_empty() { + return "~".to_string(); + } + return format!("~{}{}", std::path::MAIN_SEPARATOR, relative_path.display()); + } + } + + path.display().to_string() +} + +fn relative_to_home(path: &std::path::Path, home: &std::path::Path) -> Option { + if let Ok(relative) = path.strip_prefix(home) { + return Some(relative.to_path_buf()); + } + + #[cfg(windows)] + { + let mut path_components = path.components(); + for home_component in home.components() { + let path_component = path_components.next()?; + if !path_component + .as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(&home_component.as_os_str().to_string_lossy()) + { + return None; + } + } + return Some(path_components.as_path().to_path_buf()); + } + + #[cfg(not(windows))] + None +} + +pub(crate) fn decode_local_path_title(encoded: &str) -> Option { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .ok()?; + let path = String::from_utf8(bytes).ok()?; + let path = std::path::PathBuf::from(path); + path.is_absolute().then_some(path) +} + +fn default_local_directory() -> Option { + directories::BaseDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .filter(|path| path.is_dir()) +} + +fn initial_local_title() -> String { + default_local_directory() + .map(|path| compact_local_path(&path)) + .unwrap_or_else(|| { + if cfg!(windows) { + "PowerShell".to_string() + } else { + "Local".to_string() + } + }) +} + +fn connecting_sftp_state() -> crate::terminal::SftpUiState { + let mut expanded_directories = std::collections::HashSet::new(); + expanded_directories.insert("/".to_string()); + crate::terminal::SftpUiState { + current_path: "/".into(), + status: rust_i18n::t!("sftp_connecting").to_string(), + directory_cache: std::collections::HashMap::new(), + expanded_directories, + loading_directories: std::collections::HashSet::new(), + directory_errors: std::collections::HashMap::new(), + selected_path: None, + preview: None, + selected_entries: std::collections::HashSet::new(), + home_dir: "/".into(), + home_dir_resolved: false, + } +} + +fn save_pane_layout(layout: &PaneLayout) -> SavedPaneLayout { + match layout { + PaneLayout::Single(tab_id) => SavedPaneLayout::Single { + tab_id: tab_id.clone(), + }, + PaneLayout::Horizontal(children, ratio) => SavedPaneLayout::Horizontal { + children: children.iter().map(save_pane_layout).collect(), + ratio: *ratio, + }, + PaneLayout::Vertical(children, ratio) => SavedPaneLayout::Vertical { + children: children.iter().map(save_pane_layout).collect(), + ratio: *ratio, + }, + } +} + +fn restore_pane_layout(layout: &SavedPaneLayout) -> PaneLayout { + match layout { + SavedPaneLayout::Single { tab_id } => PaneLayout::Single(tab_id.clone()), + SavedPaneLayout::Horizontal { children, ratio } => PaneLayout::Horizontal( + children.iter().map(restore_pane_layout).collect(), + (*ratio).clamp(0.1, 0.9), + ), + SavedPaneLayout::Vertical { children, ratio } => PaneLayout::Vertical( + children.iter().map(restore_pane_layout).collect(), + (*ratio).clamp(0.1, 0.9), + ), + } +} + impl Ashell { + pub(crate) fn apply_local_directory_change(&mut self, tab_id: &str, path: std::path::PathBuf) { + let title = compact_local_path(&path); + let is_local = if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == tab_id) { + if tab.kind == TabKind::Local { + if tab.local_cwd.as_ref() == Some(&path) && tab.title == title { + return; + } + tab.title = title.clone(); + tab.dynamic_title = title.clone(); + tab.local_cwd = Some(path); + true + } else { + false + } + } else { + false + }; + + if !is_local { + return; + } + + if let Some(group) = self + .tab_groups + .iter_mut() + .find(|group| group.pane_root.contains(tab_id)) + { + let is_focused = self.active_tab.as_deref() == Some(tab_id); + let is_single_pane = matches!(&group.pane_root, PaneLayout::Single(_)); + if is_focused || is_single_pane { + group.title = title; + } + } + self.save_tabs_state_background(); + } + + pub(crate) fn capture_tabs_state(&mut self) { + if !self.config.remember_tabs() { + self.config.set_saved_tabs(None); + return; + } + + self.sync_pane_root_to_group(); + let default_local_cwd = default_local_directory(); + let groups = self + .tab_groups + .iter() + .filter_map(|group| { + let pane_ids = group.pane_root.tab_ids(); + let tabs = pane_ids + .iter() + .filter_map(|tab_id| { + let tab = self.tabs.iter().find(|tab| tab.id.as_str() == *tab_id)?; + match tab.kind { + TabKind::Local => Some(SavedTerminalTab::Local { + id: tab.id.clone(), + cwd: tab.local_cwd.clone().or_else(|| default_local_cwd.clone()), + terminal_encoding: tab.text_encoding(), + }), + TabKind::Ssh => { + tab.session.clone().map(|session| SavedTerminalTab::Ssh { + id: tab.id.clone(), + session, + }) + } + TabKind::Serial => { + tab.session.clone().map(|session| SavedTerminalTab::Serial { + id: tab.id.clone(), + session, + }) + } + } + }) + .collect::>(); + + if tabs.len() != pane_ids.len() { + tracing::warn!( + "[session] skipped tab group '{}' because its panes could not be saved", + group.id + ); + return None; + } + + Some(SavedTabGroup { + id: group.id.clone(), + title: group.title.clone(), + pane_root: save_pane_layout(&group.pane_root), + tabs, + }) + }) + .collect(); + + self.config.set_saved_tabs(Some(SavedTabsState { + groups, + active_group: self.active_group.clone(), + active_tab: self.active_tab.clone(), + })); + } + + pub(crate) fn save_tabs_state_background(&mut self) { + if self.config.remember_tabs() { + self.capture_tabs_state(); + self.save_preferences_background(); + } + } + + pub(crate) fn restore_saved_tabs(&mut self, window: &mut Window, cx: &mut Context) { + if !self.config.remember_tabs() { + return; + } + let Some(saved_state) = self.config.saved_tabs().cloned() else { + return; + }; + + let requested_active_group = saved_state.active_group; + let requested_active_tab = saved_state.active_tab; + let mut restored_group_ids = std::collections::HashSet::new(); + let mut restored_tab_ids = std::collections::HashSet::new(); + + for saved_group in saved_state.groups { + let SavedTabGroup { + id: group_id, + title, + pane_root, + tabs, + } = saved_group; + if group_id.is_empty() || !restored_group_ids.insert(group_id.clone()) { + continue; + } + + let mut group_tab_ids = std::collections::HashSet::new(); + for saved_tab in tabs { + let (tab_id, mut tab) = match saved_tab { + SavedTerminalTab::Local { + id, + cwd, + terminal_encoding, + } => { + if id.is_empty() || restored_tab_ids.contains(&id) { + continue; + } + let cwd = cwd + .filter(|path| path.is_absolute() && path.is_dir()) + .or_else(default_local_directory); + let title = cwd + .as_deref() + .map(compact_local_path) + .unwrap_or_else(initial_local_title); + let backend_events = + crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone()); + let backend = match local::spawn_local_terminal_at( + id.clone(), + DEFAULT_COLS, + DEFAULT_ROWS, + backend_events.clone(), + cwd.as_deref(), + ) { + Ok(backend) => backend, + Err(err) => { + tracing::warn!( + "[session] failed to restore local tab '{}': {err:#}", + id + ); + continue; + } + }; + let mut tab = + TerminalTab::new_local(id.clone(), title, backend, backend_events); + tab.local_cwd = cwd; + tab.set_text_encoding(terminal_encoding); + (id, tab) + } + SavedTerminalTab::Ssh { id, session } => { + if id.is_empty() || restored_tab_ids.contains(&id) { + continue; + } + let backend_events = + crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone()); + let mut tab = TerminalTab::new_ssh( + id.clone(), + &session, + crate::terminal::BackendTx::Pending, + backend_events, + ); + let pending_reason = t!("ssh_reconnect_pending").to_string(); + tab.status = pending_reason.clone(); + tab.disconnected_reason = Some(pending_reason); + (id, tab) + } + SavedTerminalTab::Serial { id, session } => { + if id.is_empty() || restored_tab_ids.contains(&id) { + continue; + } + let backend_events = + crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone()); + let backend = crate::backend::serial::spawn_serial_client( + self.runtime.handle(), + id.clone(), + session.clone(), + backend_events.clone(), + ); + ( + id.clone(), + TerminalTab::new_serial( + id, + &session, + crate::terminal::BackendTx::Serial(backend), + backend_events, + ), + ) + } + }; + tab.resize(DEFAULT_COLS, DEFAULT_ROWS); + group_tab_ids.insert(tab_id.clone()); + restored_tab_ids.insert(tab_id); + self.tabs.push(tab); + } + + let mut pane_root = restore_pane_layout(&pane_root); + let missing_tabs = pane_root + .tab_ids() + .into_iter() + .filter(|tab_id| !group_tab_ids.contains(*tab_id)) + .map(str::to_string) + .collect::>(); + for tab_id in missing_tabs { + pane_root.remove_tab(&tab_id); + } + let layout_tab_ids = pane_root + .tab_ids() + .into_iter() + .filter(|tab_id| !tab_id.is_empty()) + .map(str::to_string) + .collect::>(); + let orphaned_tab_ids = group_tab_ids + .difference(&layout_tab_ids) + .cloned() + .collect::>(); + for tab_id in orphaned_tab_ids { + if let Some(tab) = self.tabs.iter().find(|tab| tab.id == tab_id) { + tab.send_backend(BackendCommand::Close); + } + self.tabs.retain(|tab| tab.id != tab_id); + restored_tab_ids.remove(&tab_id); + } + if pane_root + .tab_ids() + .first() + .is_none_or(|tab_id| tab_id.is_empty()) + { + continue; + } + + self.tab_groups.push(TabGroup { + id: group_id.clone(), + title, + pane_root, + // Restored SSH sessions remain offline until the user confirms + // reconnecting, so their SFTP worker must remain stopped too. + sftp: None, + sftp_tab_id: None, + }); + } + + let active_group = requested_active_group + .filter(|id| self.tab_groups.iter().any(|group| group.id == *id)) + .or_else(|| self.tab_groups.first().map(|group| group.id.clone())); + let Some(active_group) = active_group else { + return; + }; + let Some(active_layout) = self + .tab_groups + .iter() + .find(|group| group.id == active_group) + .map(|group| group.pane_root.clone()) + else { + return; + }; + + self.active_group = Some(active_group.clone()); + self.pane_root = active_layout; + let active_tab = requested_active_tab + .filter(|id| self.pane_root.contains(id)) + .or_else(|| self.pane_root.tab_ids().first().map(|id| (*id).to_string())); + if let Some(active_tab) = active_tab { + self.focus_pane_with_id(active_tab); + } + if let Some(group_index) = self + .tab_groups + .iter() + .position(|group| group.id == active_group) + { + self.tabs_scroll_handle.scroll_to_item(group_index); + } + self.pending_sftp_path_sync = Some("/".into()); + self.sync_sftp_to_active_tab(); + self.sync_system_tab_to_active_group(); + self.status = "tabs restored".into(); + + // Defer the dialog until the restored view has been mounted. This also + // ensures that only the currently focused restored SSH tab is prompted. + let prompt_tab_id = self.active_tab.as_ref().and_then(|tab_id| { + self.tabs + .iter() + .find(|tab| tab.id == *tab_id && tab.kind == TabKind::Ssh && !tab.connected) + .map(|tab| tab.id.clone()) + }); + if let Some(prompt_tab_id) = prompt_tab_id { + let view = cx.entity(); + window.defer(cx, move |window, cx| { + view.update(cx, |this, cx| { + this.show_ssh_reconnect_dialog(prompt_tab_id, window, cx); + }); + }); + } + cx.notify(); + } + pub(crate) fn open_local(&mut self, cx: &mut Context) { let id = Uuid::new_v4().to_string(); - match local::spawn_local_terminal( + let initial_directory = default_local_directory(); + let backend_events = + crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone()); + match local::spawn_local_terminal_at( id.clone(), DEFAULT_COLS, DEFAULT_ROWS, - self.events_tx.clone(), + backend_events.clone(), + initial_directory.as_deref(), ) { Ok(backend) => { - let title = if cfg!(windows) { "PowerShell" } else { "Local" }.to_string(); + let title = initial_local_title(); let mut tab = - TerminalTab::new_local(id.clone(), title, backend, self.events_tx.clone()); + TerminalTab::new_local(id.clone(), title.clone(), backend, backend_events); + tab.local_cwd = initial_directory; tab.resize(DEFAULT_COLS, DEFAULT_ROWS); self.tabs.push(tab); self.active_tab = Some(id.clone()); @@ -40,18 +480,21 @@ impl Ashell { let group_id = Uuid::new_v4().to_string(); self.tab_groups.push(TabGroup { id: group_id.clone(), - title: "Local".to_string(), + title, pane_root: PaneLayout::Single(id), sftp: None, + sftp_tab_id: None, }); self.active_group = Some(group_id); self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1); + self.sync_system_tab_to_active_group(); self.status = "local terminal opened".into(); } Err(err) => { self.status = format!("failed to open local terminal: {err:#}").into(); } } + self.save_tabs_state_background(); cx.notify(); } @@ -79,6 +522,7 @@ impl Ashell { session_name }; + let is_editing = self.editing_session_id.is_some(); let existing_id = self.editing_session_id.clone(); let existing_last_used = existing_id .as_deref() @@ -97,7 +541,9 @@ impl Ashell { tracing::warn!("failed to save config: {err:#}"); } - self.open_serial_session(session, cx); + if !is_editing { + self.open_serial_session(session, cx); + } self.editing_session_id = None; self.active_dialog = None; window.close_dialog(cx); @@ -143,6 +589,7 @@ impl Ashell { } else { session_name }; + let is_editing = self.editing_session_id.is_some(); let existing_id = self.editing_session_id.clone(); let existing_last_used = existing_id .as_deref() @@ -177,12 +624,15 @@ impl Ashell { .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(); + session.terminal_encoding = self.ssh_terminal_encoding; 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); + if !is_editing { + self.open_ssh_session(session, cx); + } self.editing_session_id = None; self.active_dialog = None; window.close_dialog(cx); @@ -203,6 +653,7 @@ impl Ashell { self.ssh_auth_method = AuthMethod::Password; self.ssh_config_selected = None; self.session_protocol = "ssh".to_string(); + self.ssh_terminal_encoding = TextEncoding::Utf8; 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); @@ -228,6 +679,7 @@ impl Ashell { self.editing_session_id = Some(session.id.clone()); self.ssh_auth_method = session.auth; self.session_protocol = session.protocol.clone(); + self.ssh_terminal_encoding = session.terminal_encoding; 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); @@ -300,9 +752,9 @@ impl Ashell { .set_directory(start_dir) .pick_file(); - cx.spawn_in(window, async move |this, mut cx| { + cx.spawn_in(window, async move |this, cx| { if let Some(file) = file_dialog.await { - let _ = gpui::AsyncWindowContext::update(&mut cx, |window, cx| { + let _ = gpui::AsyncWindowContext::update(cx, |window, cx| { let _ = this.update(cx, |this, cx| { Self::set_input_value( &this.key_path_input, @@ -419,11 +871,16 @@ impl Ashell { pub(crate) fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context) { self.config.set_layout_state(None, None, None); + self.config.set_sftp_tree_panels(None); + self.config.set_sftp_file_columns(None); + self.config.set_sftp_file_columns_customized(false); self.save_preferences_background(); self.is_layout_reset = true; self.workspace_panels = cx.new(|_| crate::app::resizable::ResizableState::default()); self.body_panels = cx.new(|_| crate::app::resizable::ResizableState::default()); + self.sftp_tree_panels = cx.new(|_| crate::app::resizable::ResizableState::default()); + self.sftp_file_columns = cx.new(|_| crate::app::resizable::ResizableState::default()); cx.notify(); } @@ -442,6 +899,52 @@ impl Ashell { cx.notify(); } + pub(crate) fn set_ssh_terminal_encoding( + &mut self, + encoding: TextEncoding, + cx: &mut Context, + ) { + if self.ssh_terminal_encoding != encoding { + self.ssh_terminal_encoding = encoding; + cx.notify(); + } + } + + pub(crate) fn set_terminal_encoding( + &mut self, + tab_id: String, + encoding: TextEncoding, + cx: &mut Context, + ) { + let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == tab_id) else { + return; + }; + if !matches!(tab.kind, TabKind::Local | TabKind::Ssh) || tab.text_encoding() == encoding { + return; + } + + tab.set_text_encoding(encoding); + let saved_session = (tab.kind == TabKind::Ssh) + .then(|| tab.session.clone()) + .flatten(); + if let Some(session) = saved_session.as_ref() { + self.config.upsert(session.clone()); + } + if self.config.remember_tabs() { + self.capture_tabs_state(); + } + if (saved_session.is_some() || self.config.remember_tabs()) + && let Err(err) = self.config.save() + { + tracing::warn!("failed to save terminal encoding: {err:#}"); + } + + self.status = t!("terminal_encoding_changed", encoding = encoding.label()) + .to_string() + .into(); + cx.notify(); + } + pub(crate) fn refresh_ssh_config(&mut self) { self.ssh_config_entries = crate::session::ssh_config::parse_ssh_config().unwrap_or_default(); @@ -606,19 +1109,21 @@ impl Ashell { session.host ); let id = Uuid::new_v4().to_string(); + let backend_events = + crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone()); let backend = ssh::spawn_ssh_terminal( self.runtime.handle(), id.clone(), session.clone(), DEFAULT_COLS, DEFAULT_ROWS, - self.events_tx.clone(), + backend_events.clone(), ); self.tabs.push(TerminalTab::new_ssh( id.clone(), &session, backend, - self.events_tx.clone(), + backend_events, )); self.active_tab = Some(id.clone()); self.connection_progress = Some(crate::app::ConnectionProgress { @@ -634,15 +1139,8 @@ impl Ashell { 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(), - }), + sftp: Some(connecting_sftp_state()), + sftp_tab_id: Some(id.clone()), }); self.active_group = Some(group_id.clone()); self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1); @@ -659,7 +1157,7 @@ impl Ashell { cx.notify(); let sftp_handle = crate::sftp::spawn_sftp( self.runtime.handle(), - group_id.clone(), + id.clone(), session, self.events_tx.clone(), ); @@ -667,6 +1165,8 @@ impl Ashell { self.active_tab = Some(id.clone()); self.pending_sftp_path_sync = Some("/".into()); self.status = "ssh tab opened".into(); + self.sync_system_tab_to_active_group(); + self.save_tabs_state_background(); cx.notify(); } @@ -677,17 +1177,19 @@ impl Ashell { session.host ); let id = Uuid::new_v4().to_string(); + let backend_events = + crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone()); let backend = crate::backend::serial::spawn_serial_client( self.runtime.handle(), id.clone(), session.clone(), - self.events_tx.clone(), + backend_events.clone(), ); self.tabs.push(TerminalTab::new_serial( id.clone(), &session, crate::terminal::BackendTx::Serial(backend), - self.events_tx.clone(), + backend_events, )); self.active_tab = Some(id.clone()); self.connection_progress = Some(crate::app::ConnectionProgress { @@ -704,9 +1206,11 @@ impl Ashell { title: session.name.clone(), pane_root: PaneLayout::Single(id.clone()), sftp: None, + sftp_tab_id: None, }); self.active_group = Some(group_id.clone()); self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1); + self.sync_system_tab_to_active_group(); if let Some(session_id) = self.active_session_id() { if let Some(index) = self .config @@ -718,11 +1222,15 @@ impl Ashell { } } self.status = "serial tab opened".into(); + self.save_tabs_state_background(); cx.notify(); } pub(crate) fn remove_saved_session(&mut self, session_id: String, cx: &mut Context) { self.config.remove(&session_id); + self.selected_connection_ids.remove(&session_id); + self.selected_command_history + .retain(|(id, _)| id != &session_id); if let Err(err) = self.config.save() { tracing::warn!("failed to save config: {err:#}"); } @@ -730,6 +1238,118 @@ impl Ashell { cx.notify(); } + pub(crate) fn toggle_connection_selection( + &mut self, + session_id: String, + selected: bool, + cx: &mut Context, + ) { + if selected { + self.selected_connection_ids.insert(session_id); + } else { + self.selected_connection_ids.remove(&session_id); + } + cx.notify(); + } + + pub(crate) fn select_all_connections( + &mut self, + session_ids: Vec, + cx: &mut Context, + ) { + self.selected_connection_ids.extend(session_ids); + cx.notify(); + } + + pub(crate) fn remove_selected_sessions(&mut self, cx: &mut Context) { + let selected = self + .selected_connection_ids + .iter() + .filter(|id| self.config.get((*id).as_str()).is_some()) + .cloned() + .collect::>(); + if selected.is_empty() { + self.selected_connection_ids.clear(); + cx.notify(); + return; + } + + let count = selected.len(); + for session_id in selected { + self.config.remove(&session_id); + self.selected_command_history + .retain(|(id, _)| id != &session_id); + } + self.selected_connection_ids.clear(); + if let Err(err) = self.config.save() { + tracing::warn!("failed to save selected sessions: {err:#}"); + } + self.status = t!("connections_deleted", count = count).into(); + cx.notify(); + } + + pub(crate) fn close_command_history(&mut self, cx: &mut Context) { + let changed = self.show_command_history || !self.selected_command_history.is_empty(); + self.show_command_history = false; + self.selected_command_history.clear(); + if changed { + cx.notify(); + } + } + + pub(crate) fn toggle_command_history_selection( + &mut self, + session_id: String, + index: usize, + selected: bool, + cx: &mut Context, + ) { + let key = (session_id, index); + if selected { + self.selected_command_history.insert(key); + } else { + self.selected_command_history.remove(&key); + } + cx.notify(); + } + + pub(crate) fn set_command_history_selection( + &mut self, + entries: Vec<(String, usize)>, + selected: bool, + cx: &mut Context, + ) { + for entry in entries { + if selected { + self.selected_command_history.insert(entry); + } else { + self.selected_command_history.remove(&entry); + } + } + cx.notify(); + } + + pub(crate) fn remove_selected_command_history(&mut self, cx: &mut Context) { + let mut selected = self.selected_command_history.drain().collect::>(); + selected.sort_by(|(left_session, left_index), (right_session, right_index)| { + left_session + .cmp(right_session) + .then_with(|| right_index.cmp(left_index)) + }); + + let mut count = 0; + for (session_id, index) in selected { + if self.config.remove_command_history(&session_id, index) { + count += 1; + } + } + if count > 0 { + self.save_preferences_background(); + self.status = t!("commands_deleted", count = count).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. @@ -746,11 +1366,12 @@ impl Ashell { 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) + let backend_events = self.tabs[ix].advance_backend_events(); + // Advance the event generation before closing the old backend so its + // final events cannot be mistaken for events from the replacement. self.tabs[ix].send_backend(BackendCommand::Close); if let Some(session) = session { @@ -761,7 +1382,7 @@ impl Ashell { self.runtime.handle(), tab_id.to_string(), session.clone(), - self.events_tx.clone(), + backend_events.clone(), ); self.tabs[ix].set_backend(crate::terminal::BackendTx::Serial(backend)); } @@ -772,7 +1393,7 @@ impl Ashell { session.clone(), cols, rows, - self.events_tx.clone(), + backend_events.clone(), ); self.tabs[ix].set_backend(backend); } @@ -781,50 +1402,25 @@ impl Ashell { 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; + self.tabs[ix].terminal_title_received = 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)) + if tab_kind == crate::terminal::TabKind::Ssh + && self.active_tab.as_deref() == Some(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 session.protocol != "serial" { - 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(); - } - } - } - } + self.restart_active_sftp(); } } else { // Local tab: spawn new local shell - match local::spawn_local_terminal( + let local_cwd = self.tabs[ix] + .local_cwd + .clone() + .or_else(default_local_directory); + match local::spawn_local_terminal_at( tab_id.to_string(), cols, rows, - self.events_tx.clone(), + backend_events, + local_cwd.as_deref(), ) { Ok(backend) => { // Swap the backend — preserves terminal history. @@ -832,8 +1428,7 @@ impl Ashell { 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; + self.tabs[ix].local_cwd = local_cwd; // Resize the new PTY to match the pane dimensions. self.tabs[ix].send_backend(BackendCommand::Resize { cols, rows }); } @@ -856,6 +1451,7 @@ impl Ashell { #[allow(dead_code)] pub(crate) fn activate_tab(&mut self, id: String, window: &mut Window, cx: &mut Context) { + let active_tab_changed = self.active_tab.as_deref() != Some(id.as_str()); // 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) { @@ -893,7 +1489,15 @@ impl Ashell { } } self.focus_handle.focus(window, cx); + if !matches!(self.active_kind(), Some(TabKind::Ssh)) { + self.show_command_history = false; + self.selected_command_history.clear(); + } + self.sync_sftp_to_active_tab(); self.sync_system_tab_to_active_group(); + if active_tab_changed { + self.prompt_active_ssh_reconnect_if_needed(window, cx); + } cx.notify(); } @@ -906,7 +1510,7 @@ impl Ashell { if self .connection_progress .as_ref() - .map_or(false, |p| p.tab_id == id) + .is_some_and(|p| p.tab_id == id) { self.connection_progress = None; } @@ -924,11 +1528,12 @@ impl Ashell { self.tabs[ix].send_backend(BackendCommand::Close); self.tabs.remove(ix); } + self.save_tabs_state_background(); return; }; let pane_ids = group.pane_root.tab_ids(); - let pane_ids_str: Vec<&str> = pane_ids.iter().map(|s| *s).collect(); + let pane_ids_str = pane_ids.to_vec(); let is_group_close = pane_ids.len() <= 1; tracing::info!( "[handle_tab_close] id='{}' group_panes={:?} is_group_close={}", @@ -1017,10 +1622,21 @@ impl Ashell { self.cpu_history.clear(); self.net_rx_history.clear(); self.net_tx_history.clear(); + self.remote_processes.clear(); + self.remote_ports.clear(); + self.terminating_processes.clear(); + self.remote_process_status = None; + self.remote_ports_status = None; + self.remote_processes_in_flight = false; + self.remote_ports_in_flight = false; + self.expanded_process_pid = None; self.system_status = None; + self.show_command_history = false; + self.selected_command_history.clear(); for (_, handle) in self.sftp_handles.drain() { handle.close(); } + self.save_tabs_state_background(); return; } @@ -1057,7 +1673,13 @@ impl Ashell { self.focus_pane_with_id(active_id); } } + if !matches!(self.active_kind(), Some(TabKind::Ssh)) { + self.show_command_history = false; + self.selected_command_history.clear(); + } + self.sync_sftp_to_active_tab(); self.sync_system_tab_to_active_group(); + self.save_tabs_state_background(); } pub(crate) fn focus_terminal( @@ -1191,21 +1813,36 @@ impl Ashell { Some(tab) => tab, None => return, }; + let local_cwd = current_tab + .local_cwd + .clone() + .or_else(default_local_directory); let new_id = Uuid::new_v4().to_string(); + let backend_events = + crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone()); let mut tab = match current_tab.kind { TabKind::Local => { - match local::spawn_local_terminal( + match local::spawn_local_terminal_at( new_id.clone(), DEFAULT_COLS, DEFAULT_ROWS, - self.events_tx.clone(), + backend_events.clone(), + local_cwd.as_deref(), ) { - Ok(backend) => TerminalTab::new_local( - new_id.clone(), - "Local".into(), - backend, - self.events_tx.clone(), - ), + Ok(backend) => { + let title = local_cwd + .as_deref() + .map(compact_local_path) + .unwrap_or_else(initial_local_title); + let mut tab = TerminalTab::new_local( + new_id.clone(), + title, + backend, + backend_events.clone(), + ); + tab.local_cwd = local_cwd; + tab + } Err(err) => { self.status = format!("failed to split: {err:#}").into(); cx.notify(); @@ -1225,16 +1862,9 @@ impl Ashell { session.clone(), DEFAULT_COLS, DEFAULT_ROWS, - self.events_tx.clone(), + backend_events.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()) + TerminalTab::new_ssh(new_id.clone(), &session, backend, backend_events.clone()) } TabKind::Serial => { let Some(session) = current_tab.session.clone() else { @@ -1246,13 +1876,13 @@ impl Ashell { self.runtime.handle(), new_id.clone(), session.clone(), - self.events_tx.clone(), + backend_events.clone(), ); TerminalTab::new_serial( new_id.clone(), &session, crate::terminal::BackendTx::Serial(backend), - self.events_tx.clone(), + backend_events, ) } }; @@ -1295,6 +1925,8 @@ impl Ashell { } self.focused_pane_path = new_full_path; self.active_tab = Some(new_id); + self.sync_sftp_to_active_tab(); + self.sync_system_tab_to_active_group(); self.status = "pane split".into(); tracing::info!( "[split] DONE: pane_root={:?} focused_path={:?} active_tab={:?} tabs={}", @@ -1303,19 +1935,27 @@ impl Ashell { self.active_tab, self.tabs.len(), ); + self.save_tabs_state_background(); cx.notify(); } - pub(crate) fn focus_adjacent_pane(&mut self, direction: &str, cx: &mut Context) { + pub(crate) fn focus_adjacent_pane( + &mut self, + direction: &str, + window: &mut Window, + cx: &mut Context, + ) { if self.focused_pane_path.is_empty() { return; } + let mut active_tab_changed = false; 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()); + active_tab_changed = changed; self.active_tab = Some(id_owned); // Clear stale search state when switching to a different pane. if changed && self.search_active { @@ -1324,9 +1964,16 @@ impl Ashell { self.search_current = 0; self.search_target_tab = None; } + if changed { + self.sync_sftp_to_active_tab(); + self.sync_system_tab_to_active_group(); + } } cx.notify(); } + if active_tab_changed { + self.prompt_active_ssh_reconnect_if_needed(window, cx); + } } fn first_leaf_path(layout: &PaneLayout) -> Vec { @@ -1437,6 +2084,7 @@ impl Ashell { window: &mut Window, cx: &mut Context, ) { + let previous_active_tab = self.active_tab.clone(); // Save current group state if let Some(current_group_id) = self.active_group.clone() { if let Some(group) = self @@ -1458,7 +2106,12 @@ impl Ashell { } self.focus_handle.focus(window, cx); } + self.sync_sftp_to_active_tab(); self.sync_system_tab_to_active_group(); + self.save_tabs_state_background(); + if previous_active_tab != self.active_tab { + self.prompt_active_ssh_reconnect_if_needed(window, cx); + } cx.notify(); } @@ -1470,41 +2123,120 @@ impl Ashell { } } - 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()); - } - } - } + fn update_active_sftp_binding(&mut self, force: bool) { + let Some(group_id) = self.active_group.clone() else { + return; + }; + let target = self.active_tab.as_ref().and_then(|active_id| { + self.tabs + .iter() + .find(|tab| { + tab.id == *active_id && tab.kind == TabKind::Ssh && (tab.connected || force) + }) + .and_then(|tab| tab.session.clone().map(|session| (tab.id.clone(), session))) + }); + let target_tab_id = target.as_ref().map(|(tab_id, _)| tab_id.as_str()); + let current_tab_id = self + .tab_groups + .iter() + .find(|group| group.id == group_id) + .and_then(|group| group.sftp_tab_id.clone()); + let current_session_id = current_tab_id.as_ref().and_then(|tab_id| { + self.tabs + .iter() + .find(|tab| tab.id == *tab_id) + .and_then(|tab| tab.session.as_ref()) + .map(|session| session.id.clone()) + }); + let target_session_id = target.as_ref().map(|(_, session)| session.id.as_str()); + + if !force { + if current_tab_id.as_deref() == target_tab_id { + return; + } + if current_session_id.as_deref() == target_session_id + && target_session_id.is_some_and(|session_id| !session_id.is_empty()) + && self.sftp_handles.contains_key(&group_id) + { + return; } } - // 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 let Some(handle) = self.sftp_handles.remove(&group_id) { + handle.close(); + } + if let Some(group) = self + .tab_groups + .iter_mut() + .find(|group| group.id == group_id) + { + group.sftp_tab_id = target.as_ref().map(|(tab_id, _)| tab_id.clone()); + group.sftp = target.as_ref().map(|_| connecting_sftp_state()); + } - 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(); + if let Some((tab_id, session)) = target { + let handle = crate::sftp::spawn_sftp( + self.runtime.handle(), + tab_id, + session, + self.events_tx.clone(), + ); + self.sftp_handles.insert(group_id, handle); + self.pending_sftp_path_sync = Some("/".into()); + self.sftp_context_menu = None; + } + } + + pub(crate) fn sync_sftp_to_active_tab(&mut self) { + self.update_active_sftp_binding(false); + } + + pub(crate) fn restart_active_sftp(&mut self) { + self.update_active_sftp_binding(true); + } + + pub(crate) fn sync_system_tab_to_active_group(&mut self) { + let active_ssh_tab = self.active_tab.as_ref().and_then(|id| { + self.tabs + .iter() + .find(|tab| tab.id == *id && tab.kind == TabKind::Ssh) + }); + let new_id = active_ssh_tab.map(|tab| tab.id.clone()); + let active_ssh_status = active_ssh_tab.and_then(|tab| { + (!tab.connected).then(|| { + tab.disconnected_reason + .clone() + .unwrap_or_else(|| tab.status.clone()) + }) + }); + + if self.system_tab_id != new_id { + self.system_tab_id = new_id; + self.system = crate::system::SystemSnapshot::default(); + self.cpu_history.clear(); + self.net_rx_history.clear(); + self.net_tx_history.clear(); + self.remote_processes.clear(); + self.remote_ports.clear(); + self.terminating_processes.clear(); + self.remote_sample_in_flight = false; + self.remote_processes_in_flight = false; + self.remote_ports_in_flight = false; + self.remote_process_status = None; + self.remote_ports_status = None; + self.expanded_process_pid = None; + if let Some(status) = active_ssh_status { + self.system_status = Some(status.clone().into()); + self.remote_process_status = Some(status.into()); + } else { + self.system_status = None; + } + self.request_active_system_snapshot(); + if self.active_dialog == Some(crate::app::DialogKind::Processes) { + self.request_active_process_snapshot(); + } + if self.active_dialog == Some(crate::app::DialogKind::Ports) { + self.request_active_port_snapshot(); } } } @@ -1557,6 +2289,7 @@ impl Ashell { pub(crate) fn end_drag_split(&mut self) { self.dragging_splitter = None; self.drag_split_origin = None; + self.save_tabs_state_background(); } fn is_layout_horizontal_at(layout: &PaneLayout, path: &[usize]) -> bool { @@ -1568,7 +2301,7 @@ impl Ashell { [first, rest @ ..], ) => children .get(*first) - .map_or(false, |c| Self::is_layout_horizontal_at(c, rest)), + .is_some_and(|c| Self::is_layout_horizontal_at(c, rest)), _ => false, } } @@ -1609,7 +2342,21 @@ impl Ashell { 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); + self.active_tab = Some(tab_id.clone()); + if let Some(title) = self + .tabs + .iter() + .find(|tab| tab.id == tab_id && tab.kind == TabKind::Local) + .map(|tab| tab.title.clone()) + { + if let Some(group) = self + .tab_groups + .iter_mut() + .find(|group| group.pane_root.contains(&tab_id)) + { + group.title = title; + } + } // 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 { @@ -1618,6 +2365,14 @@ impl Ashell { self.search_current = 0; self.search_target_tab = None; } + if changed { + if !matches!(self.active_kind(), Some(TabKind::Ssh)) { + self.show_command_history = false; + self.selected_command_history.clear(); + } + self.sync_sftp_to_active_tab(); + self.sync_system_tab_to_active_group(); + } } } } diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index f6f613d..3dbbecc 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -16,10 +16,13 @@ use russh::{ client::{self, Handler}, keys::{PrivateKey, decode_secret_key, load_secret_key}, }; -use russh_sftp::client::SftpSession; +use russh_sftp::{client::SftpSession, protocol::FileAttributes}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, - sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + sync::{ + mpsc::{self, UnboundedReceiver, UnboundedSender}, + oneshot, + }, task::JoinHandle, }; use uuid::Uuid; @@ -66,15 +69,22 @@ pub enum SftpCommand { remote: String, local_dir: String, }, - EditFile { + ReadTextFile { remote_path: String, + reply: oneshot::Sender, String>>, + }, + WriteTextFile { + remote_path: String, + content: Vec, + reply: oneshot::Sender>, + }, + RenamePath { + old_path: String, + new_path: String, + reply: oneshot::Sender>, }, CreateDir(String), DeletePaths(Vec), - UploadEditedFile { - local_path: String, - remote_path: String, - }, UploadPaths { locals: Vec, remote_dir: String, @@ -90,6 +100,14 @@ use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; pub struct TransferStateFlag(pub Arc); +#[derive(Clone, Copy)] +struct TransferContext<'a> { + flag: &'a TransferStateFlag, + events: &'a std::sync::mpsc::Sender, + tab_id: &'a str, + id: &'a str, +} + impl TransferStateFlag { pub fn new() -> Self { Self(Arc::new(AtomicU8::new(0))) @@ -184,8 +202,43 @@ impl SftpHandle { .send(SftpCommand::UploadPaths { locals, remote_dir }); } - pub fn edit_file(&self, remote_path: String) { - let _ = self.commands.send(SftpCommand::EditFile { remote_path }); + pub fn read_text_file( + &self, + remote_path: String, + ) -> oneshot::Receiver, String>> { + let (reply, response) = oneshot::channel(); + let _ = self + .commands + .send(SftpCommand::ReadTextFile { remote_path, reply }); + response + } + + pub fn write_text_file( + &self, + remote_path: String, + content: Vec, + ) -> oneshot::Receiver> { + let (reply, response) = oneshot::channel(); + let _ = self.commands.send(SftpCommand::WriteTextFile { + remote_path, + content, + reply, + }); + response + } + + pub fn rename_path( + &self, + old_path: String, + new_path: String, + ) -> oneshot::Receiver> { + let (reply, response) = oneshot::channel(); + let _ = self.commands.send(SftpCommand::RenamePath { + old_path, + new_path, + reply, + }); + response } pub fn close(&self) { @@ -227,10 +280,6 @@ pub fn spawn_sftp( tab_id: tab_id.clone(), text: format!("sftp error: {err:#}"), }); - let _ = events.send(BackendEvent::Closed { - tab_id, - reason: format!("sftp error: {err:#}"), - }); } }); SftpHandle { @@ -252,17 +301,7 @@ async fn run_sftp( }); let handle = connect_and_authenticate(&session).await?; - let channel = handle - .channel_open_session() - .await - .context("open sftp channel")?; - channel - .request_subsystem(true, "sftp") - .await - .context("request sftp subsystem")?; - let sftp = SftpSession::new(channel.into_stream()) - .await - .context("sftp handshake")?; + let sftp = open_sftp_session(&handle).await?; let home = sftp .canonicalize(".") @@ -310,9 +349,15 @@ async fn run_sftp( }; if let Err(err) = emit_entries(&events, &tab_id, &sftp, &actual_path).await { + let reason = format!("list failed: {err:#}"); + let _ = events.send(BackendEvent::SftpDirectoryFailed { + tab_id: tab_id.clone(), + path: actual_path, + reason: reason.clone(), + }); let _ = events.send(BackendEvent::SftpStatus { tab_id: tab_id.clone(), - text: format!("list failed: {err:#}"), + text: reason, }); } } @@ -354,33 +399,30 @@ async fn run_sftp( let commands_tx_clone = commands_tx.clone(); tokio::spawn(async move { - let Ok(channel) = handle_clone.channel_open_session().await else { - return; - }; - let Ok(_) = channel.request_subsystem(true, "sftp").await else { - return; - }; - let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { - return; - }; + let result = async { + let sftp_session = open_sftp_session(&handle_clone).await?; + let _ = events_clone.send(BackendEvent::SftpStatus { + tab_id: tab_id_clone.clone(), + text: t!("downloading_file", base = base_name(&remote)).to_string(), + }); + let transfer = TransferContext { + flag: &flag, + events: &events_clone, + tab_id: &tab_id_clone, + id: &id, + }; + download_path_impl( + &handle_clone, + &sftp_session, + &remote, + Path::new(&local_dir), + transfer, + ) + .await + } + .await; - let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), - text: t!("downloading_file", base = base_name(&remote)).to_string(), - }); - - match download_path_impl( - &handle_clone, - &sftp_session, - &remote, - Path::new(&local_dir), - flag, - &events_clone, - &tab_id_clone, - &id, - ) - .await - { + match result { Ok(summary) => { let _ = events_clone.send(BackendEvent::SftpStatus { tab_id: tab_id_clone, @@ -467,32 +509,26 @@ async fn run_sftp( let commands_tx_clone = commands_tx.clone(); tokio::spawn(async move { - let Ok(channel) = handle_clone.channel_open_session().await else { - return; - }; - let Ok(_) = channel.request_subsystem(true, "sftp").await else { - return; - }; - let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { - return; - }; + let result = async { + let sftp_session = open_sftp_session(&handle_clone).await?; + let _ = events_clone.send(BackendEvent::SftpStatus { + tab_id: tab_id_clone.clone(), + text: t!("uploading").to_string(), + }); + upload_paths_impl( + &sftp_session, + &locals, + &remote_dir, + flag, + &events_clone, + &tab_id_clone, + &id, + ) + .await + } + .await; - let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), - text: t!("uploading").to_string(), - }); - - match upload_paths_impl( - &sftp_session, - &locals, - &remote_dir, - flag, - &events_clone, - &tab_id_clone, - &id, - ) - .await - { + match result { Ok(summary) => { let _ = events_clone.send(BackendEvent::SftpStatus { tab_id: tab_id_clone.clone(), @@ -530,150 +566,41 @@ async fn run_sftp( let _ = commands_tx_clone.send(SftpCommand::TransferFinished(id)); }); } - SftpCommand::EditFile { remote_path } => { - let id = uuid::Uuid::new_v4().to_string(); - let config = crate::session::config::ConfigStore::load().unwrap(); - let tmp_dir = config.tmp_dir().unwrap_or_else(|| PathBuf::from("/tmp")); - let base = base_name(&remote_path); - let local_path = tmp_dir.join(format!("{}-{}", id, base)); - - let handle_clone = handle.clone(); - let commands_tx_clone = commands_tx.clone(); - let events_clone = events.clone(); - let tab_id_clone = tab_id.clone(); - - tokio::spawn(async move { - let flag = TransferStateFlag::new(); - let Ok(channel) = handle_clone.channel_open_session().await else { - return; - }; - let Ok(_) = channel.request_subsystem(true, "sftp").await else { - return; - }; - let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { - return; - }; - - let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), - text: t!("downloading_file", base = base).to_string(), - }); - - if let Err(err) = download_file_impl( - &sftp_session, - &remote_path, - &local_path, - &flag, - &events_clone, - &tab_id_clone, - "edit-download", - ) + SftpCommand::ReadTextFile { remote_path, reply } => { + let result = read_text_file_impl(&sftp, &remote_path) .await - { - let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), - text: format!("Edit download failed: {err:#}"), - }); - return; - } - - if let Err(err) = open::that(&local_path) { - let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), - text: format!("Failed to open editor: {err:#}"), - }); - return; - } - - use notify::Watcher; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut watcher = match notify::recommended_watcher( - move |res: notify::Result| { - if let Ok(event) = res { - if event.kind.is_modify() { - let _ = tx.send(()); - } - } - }, - ) { - Ok(w) => w, - Err(_) => return, - }; - - if let Err(_) = watcher.watch(&local_path, notify::RecursiveMode::NonRecursive) - { - return; - } - - while let Some(_) = rx.recv().await { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - while let Ok(_) = rx.try_recv() {} // drain pending - - if commands_tx_clone - .send(SftpCommand::UploadEditedFile { - local_path: local_path.to_string_lossy().to_string(), - remote_path: remote_path.clone(), - }) - .is_err() - { - break; - } - } - }); + .map_err(|err| format!("{err:#}")); + let _ = reply.send(result); } - SftpCommand::UploadEditedFile { - local_path, + SftpCommand::WriteTextFile { remote_path, + content, + reply, } => { - let handle_clone = handle.clone(); - let events_clone = events.clone(); - let tab_id_clone = tab_id.clone(); - - tokio::spawn(async move { - let flag = TransferStateFlag::new(); - let Ok(channel) = handle_clone.channel_open_session().await else { - return; - }; - let Ok(_) = channel.request_subsystem(true, "sftp").await else { - return; - }; - let Ok(sftp_session) = SftpSession::new(channel.into_stream()).await else { - return; - }; - - let transferred = Arc::new(AtomicU64::new(0)); - match upload_file_impl( - &sftp_session, - Path::new(&local_path), - &remote_path, - &flag, - &events_clone, - &tab_id_clone, - "edit-upload", - transferred, - None, - ) + let result = write_text_file_impl(&sftp, &remote_path, &content) .await - { - Ok(_) => { - let now = chrono::Local::now().format("%H:%M:%S"); - let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), - text: format!( - "{} ({})", - t!("auto_saved_and_uploaded", base = base_name(&remote_path)), - now - ), - }); - } - Err(err) => { - let _ = events_clone.send(BackendEvent::SftpStatus { - tab_id: tab_id_clone.clone(), - text: format!("Auto-upload failed: {err:#}"), - }); - } + .map_err(|err| format!("{err:#}")); + if result.is_ok() { + if let Some(parent) = parent_dir(&remote_path) { + let _ = commands_tx.send(SftpCommand::ListDir(parent)); } - }); + } + let _ = reply.send(result); + } + SftpCommand::RenamePath { + old_path, + new_path, + reply, + } => { + let result = rename_path_impl(&sftp, &old_path, &new_path) + .await + .map_err(|err| format!("{err:#}")); + if result.is_ok() { + if let Some(parent) = parent_dir(&old_path) { + let _ = commands_tx.send(SftpCommand::ListDir(parent)); + } + } + let _ = reply.send(result); } SftpCommand::CreateDir(path) => { let actual_path = if path == "~" { @@ -767,6 +694,22 @@ async fn run_sftp( Ok(()) } +async fn open_sftp_session( + handle: &russh::client::Handle, +) -> Result { + let channel = handle + .channel_open_session() + .await + .context("open sftp channel")?; + channel + .request_subsystem(true, "sftp") + .await + .context("request sftp subsystem")?; + SftpSession::new(channel.into_stream()) + .await + .context("sftp handshake") +} + use std::future::Future; use std::pin::Pin; @@ -851,7 +794,7 @@ async fn connect_and_authenticate( .context("password authentication failed")?, AuthMethod::Key => { let has_explicit_key = session_has_explicit_key(session); - let success = if has_explicit_key { + if has_explicit_key { let keypair = load_session_private_key(session)?; let keys = private_keys_with_algs(keypair).context("invalid private key")?; let mut success = false; @@ -896,8 +839,7 @@ async fn connect_and_authenticate( )); } success - }; - success + } } AuthMethod::Config => { // For Config auth, try the identity file from config entry, or default keys @@ -1017,7 +959,7 @@ fn expand_key_path(value: &str) -> Option { Some(Path::new(value).to_path_buf()) } -fn base_name(path: &str) -> String { +pub(crate) fn base_name(path: &str) -> String { let sep = |c: char| c == '/' || c == '\\'; path.trim_end_matches(sep) .rsplit(sep) @@ -1026,6 +968,50 @@ fn base_name(path: &str) -> String { .to_string() } +pub(crate) fn editor_language(path: &str) -> &'static str { + let name = base_name(path).to_lowercase(); + if name == "dockerfile" { + return "bash"; + } + if name == "makefile" { + return "make"; + } + + match Path::new(&name) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + { + "bash" | "zsh" | "sh" => "bash", + "c" | "h" => "c", + "cc" | "cpp" | "cxx" | "hpp" => "cpp", + "cs" => "csharp", + "css" | "scss" => "css", + "go" => "go", + "html" | "htm" => "html", + "java" => "java", + "js" | "mjs" | "cjs" => "javascript", + "json" | "jsonc" => "json", + "kt" | "kts" => "kotlin", + "lua" => "lua", + "md" | "markdown" => "markdown", + "php" => "php", + "proto" => "proto", + "py" => "python", + "rb" => "ruby", + "rs" => "rust", + "sql" => "sql", + "svelte" => "svelte", + "swift" => "swift", + "toml" => "toml", + "ts" => "typescript", + "tsx" => "tsx", + "yaml" | "yml" => "yaml", + "zig" => "zig", + _ => "text", + } +} + pub(crate) fn parent_dir(path: &str) -> Option { if path == "/" || path.is_empty() { return None; @@ -1050,6 +1036,53 @@ pub(crate) fn join_remote(parent: &str, child: &str) -> String { } } +pub(crate) fn normalize_remote_path(input: &str, current: &str, home: &str) -> String { + let input = input.trim(); + let expanded = if input.is_empty() { + current.to_string() + } else if input == "~" { + home.to_string() + } else if let Some(rest) = input.strip_prefix("~/") { + join_remote(home, rest) + } else if input.starts_with('/') { + input.to_string() + } else { + join_remote(current, input) + }; + + let mut components = Vec::new(); + for component in expanded.split('/') { + match component { + "" | "." => {} + ".." => { + components.pop(); + } + value => components.push(value), + } + } + + if components.is_empty() { + "/".to_string() + } else { + format!("/{}", components.join("/")) + } +} + +pub(crate) fn remote_path_ancestors(path: &str) -> Vec { + let normalized = normalize_remote_path(path, "/", "/"); + let mut ancestors = vec!["/".to_string()]; + let mut current = String::new(); + for component in normalized.trim_start_matches('/').split('/') { + if component.is_empty() { + continue; + } + current.push('/'); + current.push_str(component); + ancestors.push(current.clone()); + } + ancestors +} + #[allow(dead_code)] fn strip_archive_suffix(name: &str) -> &str { for suffix in [".tar.gz", ".tgz", ".zip", ".tar"] { @@ -1119,6 +1152,97 @@ async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result Result> { + let metadata = sftp + .metadata(path) + .await + .with_context(|| format!("read metadata for {path}"))?; + if metadata.size.unwrap_or(0) > MAX_INLINE_EDIT_BYTES as u64 { + return Err(anyhow!("file exceeds the 2 MB in-app editing limit")); + } + + let remote_file = sftp + .open(path) + .await + .with_context(|| format!("open remote {path}"))?; + let mut limited = remote_file.take((MAX_INLINE_EDIT_BYTES + 1) as u64); + let mut content = Vec::new(); + limited + .read_to_end(&mut content) + .await + .with_context(|| format!("read remote {path}"))?; + if content.len() > MAX_INLINE_EDIT_BYTES { + return Err(anyhow!("file exceeds the 2 MB in-app editing limit")); + } + + Ok(content) +} + +async fn write_text_file_impl(sftp: &SftpSession, path: &str, content: &[u8]) -> Result<()> { + if content.len() > MAX_INLINE_EDIT_BYTES { + return Err(anyhow!("file exceeds the 2 MB in-app editing limit")); + } + + let permissions = sftp + .metadata(path) + .await + .ok() + .and_then(|meta| meta.permissions); + let temporary_path = format!("{path}.ashell-{}.tmp", Uuid::new_v4()); + let write_result = async { + let mut remote_file = sftp + .create(temporary_path.as_str()) + .await + .with_context(|| format!("open remote temporary file {temporary_path}"))?; + remote_file + .write_all(content) + .await + .with_context(|| format!("write remote temporary file {temporary_path}"))?; + remote_file + .flush() + .await + .with_context(|| format!("flush remote temporary file {temporary_path}"))?; + drop(remote_file); + + if let Some(permissions) = permissions { + sftp.set_metadata( + temporary_path.as_str(), + FileAttributes { + permissions: Some(permissions), + ..FileAttributes::default() + }, + ) + .await + .with_context(|| format!("preserve permissions for {path}"))?; + } + + sftp.rename(temporary_path.as_str(), path) + .await + .with_context(|| format!("replace remote {path}")) + } + .await; + + if let Err(err) = write_result { + let _ = sftp.remove_file(temporary_path.as_str()).await; + return Err(err); + } + Ok(()) +} + +async fn rename_path_impl(sftp: &SftpSession, old_path: &str, new_path: &str) -> Result<()> { + if old_path == new_path { + return Ok(()); + } + if sftp.metadata(new_path).await.is_ok() { + return Err(anyhow!("target already exists: {new_path}")); + } + sftp.rename(old_path, new_path) + .await + .with_context(|| format!("rename {old_path} to {new_path}")) +} + async fn preview_impl(sftp: &SftpSession, path: &str) -> Result { let metadata = sftp .metadata(path) @@ -1183,17 +1307,14 @@ async fn download_path_impl( sftp: &SftpSession, remote: &str, local_dir: &Path, - flag: TransferStateFlag, - events: &std::sync::mpsc::Sender, - tab_id: &str, - id: &str, + transfer: TransferContext<'_>, ) -> Result { tokio::fs::create_dir_all(local_dir) .await .with_context(|| format!("create {}", local_dir.display()))?; // Check for cancellation after initial setup - let state = flag.0.load(Ordering::SeqCst); + let state = transfer.flag.0.load(Ordering::SeqCst); if state == 2 { return Err(anyhow::anyhow!("transfer cancelled")); } @@ -1213,22 +1334,14 @@ async fn download_path_impl( base_name(remote), Uuid::new_v4() )); - let extracted_to = download_remote_directory_archive( - handle, - sftp, - remote, - &local_archive, - &flag, - events, - tab_id, - id, - ) - .await?; + let extracted_to = + download_remote_directory_archive(handle, sftp, remote, &local_archive, transfer) + .await?; return Ok(t!("downloaded_folder", path = extracted_to.display()).to_string()); } let local_path = local_dir.join(base_name(remote)); - download_file_impl(sftp, remote, &local_path, &flag, events, tab_id, id).await?; + download_file_impl(sftp, remote, &local_path, transfer).await?; Ok(t!("downloaded_file", path = local_path.display()).to_string()) } @@ -1237,10 +1350,7 @@ async fn download_dir_recursive( sftp: &SftpSession, remote_dir: &str, local_dir: &Path, - flag: &TransferStateFlag, - events: &std::sync::mpsc::Sender, - tab_id: &str, - id: &str, + transfer: TransferContext<'_>, ) -> Result<()> { tokio::fs::create_dir_all(local_dir) .await @@ -1253,23 +1363,11 @@ async fn download_dir_recursive( sftp, &entry.full_path, &local_path, - flag, - events, - tab_id, - id, + transfer, )) .await?; } else { - download_file_impl( - sftp, - &entry.full_path, - &local_path, - flag, - events, - tab_id, - id, - ) - .await?; + download_file_impl(sftp, &entry.full_path, &local_path, transfer).await?; let _ = maybe_extract_archive(&local_path).await; } } @@ -1281,10 +1379,7 @@ async fn download_remote_directory_archive( sftp: &SftpSession, remote_dir: &str, local_archive: &Path, - flag: &TransferStateFlag, - events: &std::sync::mpsc::Sender, - tab_id: &str, - id: &str, + transfer: TransferContext<'_>, ) -> Result { let remote_archive = format!( "/tmp/ashell-{}-{}.tar.gz", @@ -1293,7 +1388,7 @@ async fn download_remote_directory_archive( ); // Check for cancellation before creating remote archive - let state = flag.0.load(Ordering::SeqCst); + let state = transfer.flag.0.load(Ordering::SeqCst); if state == 2 { return Err(anyhow::anyhow!("transfer cancelled")); } @@ -1306,16 +1401,7 @@ async fn download_remote_directory_archive( .join(base_name(remote_dir)); let archive_download = async { - download_file_impl( - sftp, - &remote_archive, - local_archive, - flag, - events, - tab_id, - id, - ) - .await?; + download_file_impl(sftp, &remote_archive, local_archive, transfer).await?; extract_archive_to( local_archive, local_archive.parent().unwrap_or_else(|| Path::new(".")), @@ -1342,10 +1428,7 @@ async fn download_file_impl( sftp: &SftpSession, remote: &str, local: &Path, - flag: &TransferStateFlag, - events: &std::sync::mpsc::Sender, - tab_id: &str, - id: &str, + transfer: TransferContext<'_>, ) -> Result<()> { let mut remote_file = sftp .open(remote) @@ -1360,7 +1443,15 @@ async fn download_file_impl( let mut buffer = vec![0u8; 128 * 1024]; loop { - flag.yield_if_paused(events, tab_id, id, transferred, total) + transfer + .flag + .yield_if_paused( + transfer.events, + transfer.tab_id, + transfer.id, + transferred, + total, + ) .await?; let read = remote_file .read(&mut buffer) @@ -1375,9 +1466,9 @@ async fn download_file_impl( .with_context(|| format!("write {}", local.display()))?; transferred += read as u64; - let _ = events.send(BackendEvent::TransferProgress { - tab_id: tab_id.to_string(), - id: id.to_string(), + let _ = transfer.events.send(BackendEvent::TransferProgress { + tab_id: transfer.tab_id.to_string(), + id: transfer.id.to_string(), transferred, total, state: crate::terminal::TransferState::Running, @@ -1385,9 +1476,9 @@ async fn download_file_impl( } local_file.flush().await.context("flush local file")?; - let _ = events.send(BackendEvent::TransferProgress { - tab_id: tab_id.to_string(), - id: id.to_string(), + let _ = transfer.events.send(BackendEvent::TransferProgress { + tab_id: transfer.tab_id.to_string(), + id: transfer.id.to_string(), transferred, total, state: crate::terminal::TransferState::Completed, @@ -1490,14 +1581,17 @@ async fn upload_paths_impl( let transferred_clone = Arc::clone(&transferred); futures.push(async move { + let transfer = TransferContext { + flag: &flag_clone, + events: &events_clone, + tab_id: &tab_id_clone, + id: &id_clone, + }; upload_file_impl( sftp, &local_path, &remote_path, - &flag_clone, - &events_clone, - &tab_id_clone, - &id_clone, + transfer, transferred_clone, Some(total_bytes), ) @@ -1542,10 +1636,7 @@ async fn upload_file_impl( sftp: &SftpSession, local_file: &Path, remote_path: &str, - flag: &TransferStateFlag, - events: &std::sync::mpsc::Sender, - tab_id: &str, - id: &str, + transfer: TransferContext<'_>, transferred: Arc, total: Option, ) -> Result<()> { @@ -1560,7 +1651,10 @@ async fn upload_file_impl( let mut buffer = vec![0u8; 128 * 1024]; loop { let cur = transferred.load(Ordering::Relaxed); - flag.yield_if_paused(events, tab_id, id, cur, total).await?; + transfer + .flag + .yield_if_paused(transfer.events, transfer.tab_id, transfer.id, cur, total) + .await?; let read = local.read(&mut buffer).await.context("read local file")?; if read == 0 { break; @@ -1571,9 +1665,9 @@ async fn upload_file_impl( .with_context(|| format!("write remote {remote_path}"))?; let new_cur = transferred.fetch_add(read as u64, Ordering::Relaxed) + read as u64; - let _ = events.send(BackendEvent::TransferProgress { - tab_id: tab_id.to_string(), - id: id.to_string(), + let _ = transfer.events.send(BackendEvent::TransferProgress { + tab_id: transfer.tab_id.to_string(), + id: transfer.id.to_string(), transferred: new_cur, total, state: crate::terminal::TransferState::Running, @@ -1845,3 +1939,32 @@ impl Handler for SftpClientHandler { Ok(true) } } + +#[cfg(test)] +mod path_tests { + use super::{normalize_remote_path, remote_path_ancestors}; + + #[test] + fn normalizes_remote_paths_without_platform_separators() { + assert_eq!( + normalize_remote_path("~", "/tmp", "/home/demo"), + "/home/demo" + ); + assert_eq!( + normalize_remote_path("../logs", "/srv/app/current", "/home/demo"), + "/srv/app/logs" + ); + assert_eq!( + normalize_remote_path("/var//log/", "/", "/home/demo"), + "/var/log" + ); + } + + #[test] + fn builds_remote_path_ancestors_from_root() { + assert_eq!( + remote_path_ancestors("/home/demo/projects"), + vec!["/", "/home", "/home/demo", "/home/demo/projects"] + ); + } +} diff --git a/src/sftp/ops.rs b/src/sftp/ops.rs index 68fe243..fcd1432 100644 --- a/src/sftp/ops.rs +++ b/src/sftp/ops.rs @@ -6,26 +6,6 @@ use crate::{ terminal, }; -pub(crate) fn is_editable_text_file(filename: &str) -> bool { - let lower = filename.to_lowercase(); - let ext = std::path::Path::new(&lower) - .extension() - .and_then(|s| s.to_str()) - .unwrap_or(""); - let known_exts = [ - "txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", - "css", "md", "toml", "csv", "log", "cfg", - ]; - if known_exts.contains(&ext) { - return true; - } - let known_names = ["dockerfile", "makefile", ".gitignore", ".env"]; - if known_names.contains(&lower.as_str()) { - return true; - } - false -} - impl Ashell { pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> { self.active_group @@ -49,13 +29,107 @@ impl Ashell { } pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context) { - if let Some(handle) = self.active_sftp_handle() { - tracing::info!("[sftp] navigating to directory: '{}'", path); - handle.list_dir(path.clone()); - if let Some(sftp) = self.active_sftp_mut() { - sftp.current_path = path; - self.pending_sftp_path_sync = Some(sftp.current_path.clone()); + let Some((current_path, home_dir)) = self + .active_sftp() + .map(|sftp| (sftp.current_path.clone(), sftp.home_dir.clone())) + else { + return; + }; + let path = crate::sftp::normalize_remote_path(&path, ¤t_path, &home_dir); + let Some(handle) = self.active_sftp_handle().cloned() else { + return; + }; + + tracing::info!("[sftp] navigating to directory: '{}'", path); + let ancestors = crate::sftp::remote_path_ancestors(&path); + let mut paths_to_load = Vec::new(); + if let Some(sftp) = self.active_sftp_mut() { + sftp.current_path = path.clone(); + sftp.selected_path = None; + sftp.preview = None; + sftp.selected_entries.clear(); + sftp.expand_to(&path); + sftp.begin_directory_load(&path); + for ancestor in ancestors { + if ancestor != path + && !sftp.directory_cache.contains_key(&ancestor) + && !sftp.loading_directories.contains(&ancestor) + { + sftp.begin_directory_load(&ancestor); + paths_to_load.push(ancestor); + } } + } + self.pending_sftp_path_sync = Some(path.clone()); + for ancestor in paths_to_load { + handle.list_dir(ancestor); + } + handle.list_dir(path); + cx.notify(); + } + + pub(crate) fn toggle_sftp_tree_directory(&mut self, path: String, cx: &mut Context) { + let Some(handle) = self.active_sftp_handle().cloned() else { + return; + }; + let mut should_load = false; + if let Some(sftp) = self.active_sftp_mut() { + let has_error = sftp.directory_errors.contains_key(&path); + let has_cache = sftp.directory_cache.contains_key(&path); + if sftp.expanded_directories.contains(&path) && !has_error && has_cache { + sftp.expanded_directories.remove(&path); + cx.notify(); + return; + } + sftp.expanded_directories.insert(path.clone()); + should_load = (has_error || !has_cache) && !sftp.loading_directories.contains(&path); + if should_load { + sftp.begin_directory_load(&path); + } + } + if should_load { + handle.list_dir(path); + } + cx.notify(); + } + + pub(crate) fn reveal_current_sftp_directory(&mut self, cx: &mut Context) { + let Some(handle) = self.active_sftp_handle().cloned() else { + return; + }; + let Some(current_path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) else { + return; + }; + let ancestors = crate::sftp::remote_path_ancestors(¤t_path); + let mut paths_to_load = Vec::new(); + if let Some(sftp) = self.active_sftp_mut() { + sftp.expand_to(¤t_path); + for path in ancestors { + if !sftp.directory_cache.contains_key(&path) + && !sftp.loading_directories.contains(&path) + { + sftp.begin_directory_load(&path); + paths_to_load.push(path); + } + } + } + for path in paths_to_load { + handle.list_dir(path); + } + if let Some(index) = self.active_sftp().and_then(|sftp| { + sftp.tree_rows(self.show_hidden_files) + .iter() + .position(|row| row.path == current_path) + }) { + self.remote_tree_scroll_handle + .scroll_to_item(index, gpui::ScrollStrategy::Center); + } + cx.notify(); + } + + pub(crate) fn collapse_sftp_tree(&mut self, cx: &mut Context) { + if let Some(sftp) = self.active_sftp_mut() { + sftp.collapse_all(); cx.notify(); } } @@ -144,15 +218,27 @@ impl Ashell { cx.notify(); } - pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context) { + pub(crate) fn trigger_sftp_context_edit( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { let Some(menu) = self.sftp_context_menu.take() else { return; }; - if let Some(handle) = self.active_sftp_handle() { - tracing::info!("[sftp] triggering edit for file: '{}'", menu.remote_path); - handle.edit_file(menu.remote_path); - } - cx.notify(); + tracing::info!("[sftp] opening in-app editor for: '{}'", menu.remote_path); + self.show_sftp_editor_dialog(menu.remote_path, window, cx); + } + + pub(crate) fn trigger_sftp_context_rename( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some(menu) = self.sftp_context_menu.take() else { + return; + }; + self.show_sftp_rename_dialog(menu.remote_path, window, cx); } pub(crate) fn download_sftp_entry( @@ -307,7 +393,11 @@ impl Ashell { pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context) { if let Some(sftp) = self.active_sftp_mut() { if checked { - let paths: Vec = sftp.entries.iter().map(|e| e.full_path.clone()).collect(); + let paths: Vec = sftp + .current_entries() + .iter() + .map(|entry| entry.full_path.clone()) + .collect(); for path in paths { sftp.selected_entries.insert(path); } diff --git a/src/system/mod.rs b/src/system/mod.rs index 40c9f47..4fe3248 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -22,6 +22,25 @@ pub struct DiskSample { pub total_bytes: u64, } +#[derive(Debug, Clone, Default)] +pub struct RemoteProcess { + pub pid: u32, + pub user: String, + pub cpu_percent: f32, + pub memory_bytes: u64, + pub command: String, +} + +#[derive(Debug, Clone, Default)] +pub struct RemotePort { + pub protocol: String, + pub address: String, + pub port: u16, + pub state: String, + pub pid: Option, + pub process: String, +} + #[derive(Debug, Clone, Default)] pub struct SystemSnapshot { pub cpu_percent: f32, @@ -52,8 +71,8 @@ impl SystemSampler { sys.refresh_all(); let nets = Networks::new_with_refreshed_list(); let disks = Disks::new_with_refreshed_list(); - let last_rx_total = nets.iter().map(|(_, d)| d.total_received()).sum(); - let last_tx_total = nets.iter().map(|(_, d)| d.total_transmitted()).sum(); + let last_rx_total = nets.values().map(|d| d.total_received()).sum(); + let last_tx_total = nets.values().map(|d| d.total_transmitted()).sum(); Self { sys, @@ -81,8 +100,8 @@ impl SystemSampler { let swap_total = self.sys.total_swap(); let swap_used = self.sys.used_swap(); - let rx_total: u64 = self.nets.iter().map(|(_, d)| d.total_received()).sum(); - let tx_total: u64 = self.nets.iter().map(|(_, d)| d.total_transmitted()).sum(); + let rx_total: u64 = self.nets.values().map(|d| d.total_received()).sum(); + let tx_total: u64 = self.nets.values().map(|d| d.total_transmitted()).sum(); let now = Instant::now(); let elapsed = now .duration_since(self.last_instant) @@ -228,8 +247,144 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result { }) } +pub fn remote_processes_from_ps(raw: &str) -> Vec { + raw.lines() + .filter_map(|line| { + if let Some(record) = line.strip_prefix("PROCESS\t") { + let mut fields = record.splitn(5, '\t'); + let pid = fields.next()?.parse::().ok()?; + let user = fields.next()?.to_string(); + let cpu_percent = fields.next()?.parse::().ok()?.max(0.0); + let memory_bytes = fields.next()?.parse::().ok()?; + let command = fields.next()?.trim().to_string(); + return (!command.is_empty()).then_some(RemoteProcess { + pid, + user, + cpu_percent, + memory_bytes, + command, + }); + } + + let mut fields = line.split_whitespace(); + let pid = fields.next()?.parse::().ok()?; + let user = fields.next()?.to_string(); + let cpu_percent = fields.next()?.parse::().ok()?.max(0.0); + let memory_bytes = fields.next()?.parse::().ok()?.saturating_mul(1024); + let command = fields.collect::>().join(" "); + if command.is_empty() { + return None; + } + Some(RemoteProcess { + pid, + user, + cpu_percent, + memory_bytes, + command, + }) + }) + .collect() +} + +pub fn remote_ports_from_probe(raw: &str) -> Vec { + raw.lines() + .filter_map(|line| { + let record = line.strip_prefix("PORT\t")?; + let mut fields = record.splitn(6, '\t'); + let protocol = fields.next()?.trim().to_string(); + let address = fields.next()?.trim().to_string(); + let port = fields.next()?.parse::().ok()?; + let state = fields.next()?.trim().to_string(); + let pid = match fields.next()?.trim() { + "" | "-" | "0" => None, + value => value.parse::().ok(), + }; + let process = fields.next()?.trim().to_string(); + if protocol.is_empty() || address.is_empty() || state.is_empty() { + return None; + } + Some(RemotePort { + protocol, + address, + port, + state, + pid, + process: if process.is_empty() { + "-".to_string() + } else { + process + }, + }) + }) + .collect() +} + fn parse_u64(kv: &BTreeMap, key: &str) -> u64 { kv.get(key) .and_then(|value| value.parse::().ok()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::{remote_ports_from_probe, remote_processes_from_ps}; + + #[test] + fn parses_remote_process_rows_and_rss_bytes() { + let processes = remote_processes_from_ps( + " 42 alice 12.5 2048 worker --queue main\n 7 root 0.0 512 sshd\n", + ); + + assert_eq!(processes.len(), 2); + assert_eq!(processes[0].pid, 42); + assert_eq!(processes[0].user, "alice"); + assert_eq!(processes[0].cpu_percent, 12.5); + assert_eq!(processes[0].memory_bytes, 2 * 1024 * 1024); + assert_eq!(processes[0].command, "worker --queue main"); + } + + #[test] + fn parses_structured_current_process_rows() { + let processes = + remote_processes_from_ps("PROCESS\t42\talice\t37.25\t2097152\tworker --queue main\n"); + + assert_eq!(processes.len(), 1); + assert_eq!(processes[0].pid, 42); + assert_eq!(processes[0].user, "alice"); + assert_eq!(processes[0].cpu_percent, 37.25); + assert_eq!(processes[0].memory_bytes, 2 * 1024 * 1024); + assert_eq!(processes[0].command, "worker --queue main"); + } + + #[test] + fn skips_malformed_remote_process_rows() { + let processes = remote_processes_from_ps("PID USER CPU RSS COMMAND\n9 root bad 64 init\n"); + + assert!(processes.is_empty()); + } + + #[test] + fn parses_structured_remote_port_rows() { + let ports = remote_ports_from_probe( + "PORT\ttcp\t0.0.0.0\t22\tLISTEN\t123\tsshd\nPORT\tudp\t127.0.0.1\t5353\tUNCONN\t-\t-\n", + ); + + assert_eq!(ports.len(), 2); + assert_eq!(ports[0].protocol, "tcp"); + assert_eq!(ports[0].address, "0.0.0.0"); + assert_eq!(ports[0].port, 22); + assert_eq!(ports[0].state, "LISTEN"); + assert_eq!(ports[0].pid, Some(123)); + assert_eq!(ports[0].process, "sshd"); + assert_eq!(ports[1].pid, None); + } + + #[test] + fn skips_malformed_remote_port_rows() { + let ports = remote_ports_from_probe( + "PORT\ttcp\t0.0.0.0\tbad\tLISTEN\t1\tsshd\nPORT\ttcp\t\t80\tLISTEN\t1\tnginx\n", + ); + + assert!(ports.is_empty()); + } +} diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 524cc73..23aa8d6 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -156,6 +156,19 @@ pub struct TerminalElement { search_highlights: Option>, } +pub(crate) struct TerminalElementConfig { + pub(crate) view: Entity, + pub(crate) focus_handle: FocusHandle, + pub(crate) snapshot: RenderSnapshot, + pub(crate) marked_text: Option, + pub(crate) font_family: SharedString, + pub(crate) font_size: Pixels, + pub(crate) line_height: Pixels, + pub(crate) cell_width: Pixels, + pub(crate) tab_id: String, + pub(crate) search_highlights: Option>, +} + pub struct PrepaintState { bounds: Bounds, metrics: TerminalMetrics, @@ -284,18 +297,19 @@ impl InputHandler for TerminalInputHandler { } impl TerminalElement { - pub fn new( - view: Entity, - focus_handle: FocusHandle, - snapshot: RenderSnapshot, - marked_text: Option, - font_family: SharedString, - font_size: Pixels, - line_height: Pixels, - cell_width: Pixels, - tab_id: String, - search_highlights: Option>, - ) -> Self { + pub(crate) fn new(config: TerminalElementConfig) -> Self { + let TerminalElementConfig { + view, + focus_handle, + snapshot, + marked_text, + font_family, + font_size, + line_height, + cell_width, + tab_id, + search_highlights, + } = config; Self { view, focus_handle, @@ -606,7 +620,7 @@ impl Element for TerminalElement { // This is 100% accurate because it is recorded during layout prepaint. let view = self.view.clone(); let tab_id = self.tab_id.clone(); - let _ = view.update(cx, |this, cx| { + view.update(cx, |this, cx| { let old_bounds = this.terminal_bounds.insert(tab_id.clone(), bounds); // Sync PTY size unconditionally on every prepaint layout pass to ensure diff --git a/src/terminal/highlight.rs b/src/terminal/highlight.rs index 57736b8..2ad099c 100644 --- a/src/terminal/highlight.rs +++ b/src/terminal/highlight.rs @@ -877,10 +877,10 @@ fn find_urls(text: &str) -> Vec { while let Some(pos) = text[start..].find("http") { let abs = start + pos; let remaining = &text[abs..]; - if remaining.starts_with("https://") || remaining.starts_with("http://") { - if abs == 0 || is_boundary(text.as_bytes()[abs - 1] as char) { - positions.push(abs); - } + if (remaining.starts_with("https://") || remaining.starts_with("http://")) + && (abs == 0 || is_boundary(text.as_bytes()[abs - 1] as char)) + { + positions.push(abs); } start = abs + 4; } @@ -967,8 +967,8 @@ pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec 0 && { - current_line.as_ref().map_or(false, |line| { - line.row_cells.last().map_or(false, |rc| { + current_line.as_ref().is_some_and(|line| { + line.row_cells.last().is_some_and(|rc| { rc.cell .flags .contains(alacritty_terminal::term::cell::Flags::WRAPLINE) diff --git a/src/terminal/input.rs b/src/terminal/input.rs index c327929..4ce62e0 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -13,7 +13,7 @@ use crate::{ }; thread_local! { - static LAST_DRAG_SCROLL: std::cell::Cell> = std::cell::Cell::new(None); + static LAST_DRAG_SCROLL: std::cell::Cell> = const { std::cell::Cell::new(None) }; } impl Ashell { @@ -42,10 +42,10 @@ impl Ashell { && !event.keystroke.modifiers.platform { match event.keystroke.key.to_ascii_lowercase().as_str() { - "h" => self.focus_adjacent_pane("left", cx), - "j" => self.focus_adjacent_pane("down", cx), - "k" => self.focus_adjacent_pane("up", cx), - "l" => self.focus_adjacent_pane("right", cx), + "h" => self.focus_adjacent_pane("left", window, cx), + "j" => self.focus_adjacent_pane("down", window, cx), + "k" => self.focus_adjacent_pane("up", window, cx), + "l" => self.focus_adjacent_pane("right", window, cx), "q" => { if let Some(active_id) = self.active_tab.clone() { self.close_tab(active_id, cx); @@ -161,21 +161,19 @@ impl Ashell { let Some(active_id) = self.active_tab.clone() else { return; }; - let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else { + let Some(app_cursor_mode) = self + .tabs + .iter() + .find(|tab| tab.id == active_id) + .map(|tab| tab.app_cursor_mode()) + else { return; }; - if tab.render_snapshot(false).display_offset > 0 { - tab.scroll_to_bottom(); - } - tab.clear_selection(); - - if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) { - tab.send_backend(BackendCommand::Input(bytes)); - window.prevent_default(); - cx.stop_propagation(); - cx.notify(); - } + let Some(bytes) = encode_key(&event.keystroke, app_cursor_mode, false) else { + return; + }; + self.send_terminal_input(bytes, window, cx); } pub(crate) fn on_terminal_tab_action( @@ -200,6 +198,7 @@ impl Ashell { let Some(active_id) = self.active_tab.clone() else { return; }; + self.record_ssh_input(&active_id, &bytes); let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else { return; }; @@ -209,12 +208,33 @@ impl Ashell { } tab.clear_selection(); - tab.send_backend(BackendCommand::Input(bytes)); + let encoded = tab.encode_input(&bytes); + tab.send_backend(BackendCommand::Input(encoded)); window.prevent_default(); cx.stop_propagation(); cx.notify(); } + pub(crate) fn execute_ssh_history_command( + &mut self, + command: String, + window: &mut Window, + cx: &mut Context, + ) { + let is_connected_ssh = self + .active_tab + .as_ref() + .and_then(|active_id| self.tabs.iter().find(|tab| &tab.id == active_id)) + .is_some_and(|tab| tab.kind == crate::terminal::TabKind::Ssh && tab.connected); + if !is_connected_ssh { + return; + } + let mut bytes = command.into_bytes(); + bytes.push(b'\r'); + self.send_terminal_input(bytes, window, cx); + self.close_command_history(cx); + } + pub(crate) fn active_terminal_selection_text(&self) -> Option { let active_id = self.active_tab.as_ref()?; self.tabs @@ -232,6 +252,11 @@ impl Ashell { let Some(active_id) = self.active_tab.clone() else { return; }; + let normalized_text = text + .replace('\x1b', "") + .replace("\r\n", "\r") + .replace('\n', "\r"); + self.record_ssh_input(&active_id, normalized_text.as_bytes()); let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else { return; }; @@ -287,6 +312,8 @@ impl Ashell { let Some(active_id) = self.active_tab.clone() else { return; }; + let bytes = text.as_bytes().to_vec(); + self.record_ssh_input(&active_id, &bytes); let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else { return; }; @@ -296,11 +323,134 @@ impl Ashell { } tab.clear_selection(); self.terminal_marked_text = None; - tab.send_backend(BackendCommand::Input(text.as_bytes().to_vec())); + let encoded = tab.encode_input(&bytes); + tab.send_backend(BackendCommand::Input(encoded)); window.invalidate_character_coordinates(); cx.notify(); } + /// Track the current SSH shell line and persist completed commands. + fn record_ssh_input(&mut self, tab_id: &str, bytes: &[u8]) { + let (session_id, cursor, is_alternate_screen_active) = { + let Some(tab) = self.tabs.iter().find(|tab| { + tab.id == tab_id && tab.kind == crate::terminal::TabKind::Ssh && tab.connected + }) else { + return; + }; + let Some(session) = tab.session.as_ref() else { + return; + }; + ( + session.id.clone(), + tab.cursor_state().map(|cursor| (cursor.row, cursor.col)), + tab.is_alternate_screen_active(), + ) + }; + + if is_alternate_screen_active { + self.ssh_command_buffers.remove(tab_id); + self.ssh_command_starts.remove(tab_id); + return; + } + + let submits_command = bytes.iter().any(|byte| matches!(*byte, b'\r' | b'\n')); + let edits_command = bytes + .iter() + .any(|byte| !matches!(*byte, b'\r' | b'\n' | b'\x03')); + if edits_command && !self.ssh_command_starts.contains_key(tab_id) { + if let Some(cursor) = cursor { + self.ssh_command_starts.insert(tab_id.to_string(), cursor); + } + } + + let mut rendered_command = if submits_command { + self.ssh_command_starts + .get(tab_id) + .copied() + .and_then(|start| { + self.tabs + .iter() + .find(|tab| tab.id == tab_id) + .map(|tab| tab.render_snapshot(false)) + .and_then(|snapshot| terminal_command_text(&snapshot, start)) + }) + } else { + None + }; + + let mut completed = Vec::new(); + let mut reset_command_start = false; + { + let buffer = self + .ssh_command_buffers + .entry(tab_id.to_string()) + .or_default(); + let mut in_escape = false; + let mut in_csi = false; + for character in String::from_utf8_lossy(bytes).chars() { + if in_escape { + if character == '[' || character == 'O' { + in_escape = false; + in_csi = true; + } else { + in_escape = false; + } + continue; + } + if in_csi { + if character.is_ascii_alphabetic() || character == '~' { + in_csi = false; + } + continue; + } + match character { + '\x1b' => in_escape = true, + '\r' | '\n' => { + let command = + merge_command_text(rendered_command.take().as_deref(), buffer); + if !command.is_empty() { + completed.push(command); + } + buffer.clear(); + reset_command_start = true; + } + '\u{8}' | '\u{7f}' => { + buffer.pop(); + } + '\u{15}' => buffer.clear(), + '\u{3}' => { + buffer.clear(); + reset_command_start = true; + } + '\u{17}' => { + let trimmed_len = buffer.trim_end().len(); + buffer.truncate(trimmed_len); + while let Some((index, character)) = buffer.char_indices().next_back() { + if character.is_whitespace() { + break; + } + buffer.truncate(index); + } + } + character if !character.is_control() => buffer.push(character), + _ => {} + } + } + } + if reset_command_start { + self.ssh_command_starts.remove(tab_id); + } + + let mut changed = false; + for command in completed { + changed |= self.config.add_command_history(&session_id, command); + } + if changed { + self.selected_command_history.clear(); + self.save_preferences_background(); + } + } + pub(crate) fn on_terminal_right_click( &mut self, _event: &MouseDownEvent, @@ -445,15 +595,11 @@ impl Ashell { if should_scroll { if row == 0 { scroll_delta = 2; - } else if row == 1 { - scroll_delta = 1; - } else if row == 2 { + } else if row == 1 || row == 2 { scroll_delta = 1; } else if row == max_row { scroll_delta = -2; - } else if row == max_row.saturating_sub(1) { - scroll_delta = -1; - } else if row == max_row.saturating_sub(2) { + } else if row == max_row.saturating_sub(1) || row == max_row.saturating_sub(2) { scroll_delta = -1; } } @@ -492,7 +638,7 @@ impl Ashell { let bounds = self.terminal_bounds.get(active_id)?; if !bounds.contains(&position) { // Try other pane bounds - for (_, b) in &self.terminal_bounds { + for b in self.terminal_bounds.values() { if b.contains(&position) { // Found a different pane - focus it // (this path is for click-to-focus; handled via focus_terminal) @@ -528,7 +674,7 @@ impl Ashell { // Platform modifier (Cmd on macOS, Ctrl on Windows/Linux) + scroll → zoom terminal font size if event.modifiers.platform { let delta = match event.delta { - ScrollDelta::Lines(point) => point.y as f32 * 20.0, + ScrollDelta::Lines(point) => point.y * 20.0, ScrollDelta::Pixels(point) => point.y.as_f32(), }; self.terminal_zoom_accumulator += delta; @@ -629,3 +775,60 @@ impl Ashell { } } } + +fn terminal_command_text( + snapshot: &crate::terminal::RenderSnapshot, + start: (usize, usize), +) -> Option { + let logical_lines = + crate::terminal::highlight::build_logical_lines(&snapshot.cells, snapshot.rows); + for line in logical_lines { + if !line.byte_to_cell.iter().any(|(row, _)| *row == start.0) { + continue; + } + + let start_byte = line + .byte_to_cell + .iter() + .position(|(row, col)| *row > start.0 || (*row == start.0 && *col >= start.1))?; + let command = line + .text + .get(start_byte..)? + .trim_end_matches(|character: char| character == '\0' || character.is_whitespace()) + .replace('\0', ""); + if !command.trim().is_empty() { + return Some(command); + } + } + None +} + +fn merge_command_text(rendered: Option<&str>, buffered: &str) -> String { + let rendered = rendered.unwrap_or_default().trim(); + let buffered = buffered.trim(); + if rendered.is_empty() { + return buffered.to_string(); + } + if buffered.is_empty() { + return rendered.to_string(); + } + if rendered.starts_with(buffered) || rendered.ends_with(buffered) { + return rendered.to_string(); + } + if buffered.starts_with(rendered) || buffered.ends_with(rendered) { + return buffered.to_string(); + } + + let overlap = buffered + .char_indices() + .map(|(index, _)| index) + .chain(std::iter::once(buffered.len())) + .filter(|index| *index > 0 && rendered.ends_with(&buffered[..*index])) + .max() + .unwrap_or(0); + if overlap > 0 { + format!("{rendered}{}", &buffered[overlap..]) + } else { + rendered.to_string() + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index eb47d32..87e5eab 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -3,7 +3,15 @@ pub mod element; pub mod highlight; pub mod input; -use std::sync::mpsc::Sender; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU32, Ordering}, + mpsc::{SendError, Sender}, + }, +}; use alacritty_terminal::{ event::{Event, EventListener}, @@ -17,7 +25,8 @@ use gpui::Keystroke; use crate::session::config::Session; use crate::sftp::{PreviewData, RemoteEntry}; -use crate::system::SystemSnapshot; +use crate::system::{RemotePort, RemoteProcess, SystemSnapshot}; +use crate::text_encoding::{StreamingDecoder, TextEncoding}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TabKind { @@ -31,11 +40,19 @@ pub enum BackendCommand { Input(Vec), Resize { cols: u16, rows: u16 }, SampleMetrics, + SampleProcesses, + SamplePorts, + TerminateProcess { pid: u32 }, Close, } #[derive(Debug, Clone)] pub enum BackendEvent { + Guarded { + current_generation: Arc, + generation: u32, + event: Box, + }, Output { tab_id: String, bytes: Vec, @@ -52,6 +69,11 @@ pub enum BackendEvent { path: String, entries: Vec, }, + SftpDirectoryFailed { + tab_id: String, + path: String, + reason: String, + }, SftpPreview { tab_id: String, preview: PreviewData, @@ -68,6 +90,31 @@ pub enum BackendEvent { tab_id: String, reason: String, }, + RemoteProcesses { + tab_id: String, + processes: Vec, + }, + RemoteProcessesUnavailable { + tab_id: String, + reason: String, + }, + RemotePorts { + tab_id: String, + ports: Vec, + }, + RemotePortsUnavailable { + tab_id: String, + reason: String, + }, + RemoteProcessTerminated { + tab_id: String, + pid: u32, + }, + RemoteProcessTerminateFailed { + tab_id: String, + pid: u32, + reason: String, + }, SftpHome { tab_id: String, home: String, @@ -92,14 +139,85 @@ pub enum BackendEvent { tab_id: String, title: String, }, + LocalDirectoryChanged { + tab_id: String, + path: std::path::PathBuf, + }, SyncFinished(crate::sync::SyncResult), } +impl BackendEvent { + pub(crate) fn into_current(self) -> Option { + match self { + Self::Guarded { + current_generation, + generation, + event, + } if current_generation.load(Ordering::Acquire) == generation => Some(*event), + Self::Guarded { .. } => None, + event => Some(event), + } + } +} + +/// Filters events emitted by superseded terminal backends. +/// +/// Every backend instance captures the tab generation that was current when it +/// was spawned. Reconnecting advances the shared generation before the old +/// backend is closed, so late events from that backend never reach the UI. +#[derive(Clone)] +pub struct GuardedBackendEventSender { + events: Sender, + current_generation: Arc, + generation: u32, +} + +impl GuardedBackendEventSender { + pub fn new(events: Sender) -> Self { + Self { + events, + current_generation: Arc::new(AtomicU32::new(0)), + generation: 0, + } + } + + pub fn send(&self, event: BackendEvent) -> Result<(), Box>> { + if self.current_generation.load(Ordering::Acquire) != self.generation { + return Ok(()); + } + self.events + .send(BackendEvent::Guarded { + current_generation: self.current_generation.clone(), + generation: self.generation, + event: Box::new(event), + }) + .map_err(Box::new) + } + + fn next_generation(&self) -> Self { + let generation = self + .current_generation + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + Self { + events: self.events.clone(), + current_generation: self.current_generation.clone(), + generation, + } + } + + fn unguarded_sender(&self) -> Sender { + self.events.clone() + } +} + #[derive(Clone)] pub enum BackendTx { Local(Sender), Ssh(tokio::sync::mpsc::UnboundedSender), Serial(tokio::sync::mpsc::UnboundedSender), + /// A restored session that is waiting for the user to confirm reconnecting. + Pending, } impl BackendTx { @@ -114,6 +232,7 @@ impl BackendTx { Self::Serial(tx) => { let _ = tx.send(command); } + Self::Pending => {} } } } @@ -122,32 +241,32 @@ pub struct TerminalTab { pub id: String, pub title: String, pub dynamic_title: String, + pub terminal_title_received: bool, + pub local_cwd: Option, pub kind: TabKind, pub status: String, pub connected: bool, pub disconnected_reason: Option, - /// Incremented each time the tab is reconnected. Used to ignore stale - /// `BackendEvent::Closed` from the previous backend after a retry. - pub backend_generation: u32, - /// Set to `true` when the current backend sends its first `Output` or - /// `Connected` event. Used to skip stale `Closed` events that arrive - /// before the new backend has started producing output. - pub backend_initialized: bool, pub session: Option, + text_encoding: TextEncoding, + output_decoder: StreamingDecoder, processor: Processor, term: Term, pub cols: u16, pub rows: u16, pub backend: std::sync::Arc>, + backend_events: GuardedBackendEventSender, pub scroll_pixel_y: f32, - pub(crate) highlight_cache: std::cell::RefCell< - Option<( - Vec, - std::collections::HashMap<(i32, i32), gpui::Hsla>, - )>, - >, + pub(crate) highlight_cache: HighlightCache, } +type HighlightCache = std::cell::RefCell< + Option<( + Vec, + std::collections::HashMap<(i32, i32), gpui::Hsla>, + )>, +>; + #[derive(Clone, Copy)] pub struct CursorState { pub row: usize, @@ -183,15 +302,218 @@ pub struct ViewportSelection { pub is_block: bool, } +#[derive(Clone, Debug)] +pub(crate) struct SftpTreeRow { + pub(crate) path: String, + pub(crate) label: String, + pub(crate) depth: usize, + pub(crate) expanded: bool, + pub(crate) loading: bool, + pub(crate) error: Option, +} + #[derive(Clone, Default)] pub struct SftpUiState { pub current_path: String, pub status: String, - pub entries: Vec, + pub directory_cache: HashMap>, + pub expanded_directories: HashSet, + pub loading_directories: HashSet, + pub directory_errors: HashMap, pub selected_path: Option, pub preview: Option, - pub selected_entries: std::collections::HashSet, + pub selected_entries: HashSet, pub home_dir: String, + pub home_dir_resolved: bool, +} + +impl SftpUiState { + pub(crate) fn current_entries(&self) -> &[RemoteEntry] { + self.directory_cache + .get(&self.current_path) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + pub(crate) fn begin_directory_load(&mut self, path: &str) { + self.loading_directories.insert(path.to_string()); + self.directory_errors.remove(path); + } + + pub(crate) fn apply_directory_entries(&mut self, path: String, entries: Vec) { + self.loading_directories.remove(&path); + self.directory_errors.remove(&path); + self.directory_cache.insert(path, entries); + } + + pub(crate) fn apply_directory_error(&mut self, path: String, reason: String) { + self.loading_directories.remove(&path); + self.directory_errors.insert(path, reason); + } + + pub(crate) fn expand_to(&mut self, path: &str) { + self.expanded_directories + .extend(crate::sftp::remote_path_ancestors(path)); + } + + pub(crate) fn collapse_all(&mut self) { + self.expanded_directories.clear(); + self.expanded_directories.insert("/".to_string()); + } + + pub(crate) fn tree_rows(&self, show_hidden: bool) -> Vec { + fn append_rows( + rows: &mut Vec, + state: &SftpUiState, + path: String, + label: String, + depth: usize, + show_hidden: bool, + ) { + let visible_directories = state.directory_cache.get(&path).map(|entries| { + entries + .iter() + .filter(|entry| entry.is_dir && (show_hidden || !entry.name.starts_with('.'))) + .cloned() + .collect::>() + }); + let expanded = state.expanded_directories.contains(&path); + + rows.push(SftpTreeRow { + path: path.clone(), + label, + depth, + expanded, + loading: state.loading_directories.contains(&path), + error: state.directory_errors.get(&path).cloned(), + }); + + if !expanded { + return; + } + if let Some(directories) = visible_directories { + for directory in directories { + append_rows( + rows, + state, + directory.full_path, + directory.name, + depth + 1, + show_hidden, + ); + } + } + } + + let mut rows = Vec::new(); + append_rows( + &mut rows, + self, + "/".to_string(), + "/".to_string(), + 0, + show_hidden, + ); + rows + } +} + +#[cfg(test)] +mod sftp_ui_tests { + use super::SftpUiState; + use crate::sftp::RemoteEntry; + + fn directory(name: &str, path: &str) -> RemoteEntry { + RemoteEntry { + name: name.to_string(), + full_path: path.to_string(), + is_dir: true, + size: 0, + modified: 0, + } + } + + #[test] + fn directory_responses_do_not_replace_the_current_path() { + let mut state = SftpUiState { + current_path: "/home/demo".to_string(), + ..SftpUiState::default() + }; + + state.apply_directory_entries("/var".to_string(), vec![directory("log", "/var/log")]); + + assert_eq!(state.current_path, "/home/demo"); + assert_eq!(state.directory_cache["/var"][0].full_path, "/var/log"); + } + + #[test] + fn tree_rows_follow_expansion_and_hidden_directory_preferences() { + let mut state = SftpUiState::default(); + state.expanded_directories.insert("/".to_string()); + state.expanded_directories.insert("/home".to_string()); + state.apply_directory_entries( + "/".to_string(), + vec![ + directory(".internal", "/.internal"), + directory("home", "/home"), + ], + ); + state.apply_directory_entries("/home".to_string(), vec![directory("demo", "/home/demo")]); + + let visible_paths = state + .tree_rows(false) + .into_iter() + .map(|row| row.path) + .collect::>(); + assert_eq!(visible_paths, vec!["/", "/home", "/home/demo"]); + + let visible_with_hidden = state + .tree_rows(true) + .into_iter() + .map(|row| row.path) + .collect::>(); + assert_eq!( + visible_with_hidden, + vec!["/", "/.internal", "/home", "/home/demo"] + ); + } +} + +#[cfg(test)] +mod backend_event_tests { + use super::{BackendEvent, GuardedBackendEventSender}; + + #[test] + fn superseded_backend_events_are_discarded() { + let (events, received) = std::sync::mpsc::channel(); + let first = GuardedBackendEventSender::new(events); + first + .send(BackendEvent::Closed { + tab_id: "tab-1".to_string(), + reason: "queued stale close".to_string(), + }) + .unwrap(); + + let second = first.next_generation(); + first + .send(BackendEvent::Output { + tab_id: "tab-1".to_string(), + bytes: b"late stale output".to_vec(), + }) + .unwrap(); + second + .send(BackendEvent::Connected { + tab_id: "tab-1".to_string(), + }) + .unwrap(); + + assert!(received.recv().unwrap().into_current().is_none()); + assert!(matches!( + received.recv().unwrap().into_current(), + Some(BackendEvent::Connected { .. }) + )); + assert!(received.try_recv().is_err()); + } } impl TerminalTab { @@ -199,7 +521,7 @@ impl TerminalTab { id: String, title: String, backend: BackendTx, - events: std::sync::mpsc::Sender, + backend_events: GuardedBackendEventSender, ) -> Self { Self::new( id, @@ -207,7 +529,7 @@ impl TerminalTab { TabKind::Local, "local shell".into(), backend, - events, + backend_events, ) } @@ -215,7 +537,7 @@ impl TerminalTab { id: String, session: &Session, backend: BackendTx, - events: std::sync::mpsc::Sender, + backend_events: GuardedBackendEventSender, ) -> Self { let mut tab = Self::new( id, @@ -226,9 +548,10 @@ impl TerminalTab { session.user, session.host, session.port ), backend, - events, + backend_events, ); tab.session = Some(session.clone()); + tab.set_text_encoding(session.terminal_encoding); tab.connected = false; tab } @@ -237,7 +560,7 @@ impl TerminalTab { id: String, session: &Session, backend: BackendTx, - events: std::sync::mpsc::Sender, + backend_events: GuardedBackendEventSender, ) -> Self { let mut tab = Self::new( id, @@ -245,7 +568,7 @@ impl TerminalTab { TabKind::Serial, format!("connecting serial://{}@{}", session.host, session.baud_rate), backend, - events, + backend_events, ); tab.session = Some(session.clone()); tab.connected = false; @@ -258,32 +581,56 @@ impl TerminalTab { kind: TabKind, status: String, backend: BackendTx, - events: std::sync::mpsc::Sender, + backend_events: GuardedBackendEventSender, ) -> Self { let shared_backend = std::sync::Arc::new(std::sync::Mutex::new(backend)); + let events = backend_events.unguarded_sender(); Self { id: id.clone(), title: title.clone(), dynamic_title: title, + terminal_title_received: false, + local_cwd: None, kind, status, connected: matches!(kind, TabKind::Local), disconnected_reason: None, - backend_generation: 0, - backend_initialized: true, session: None, + text_encoding: TextEncoding::Utf8, + output_decoder: StreamingDecoder::new(TextEncoding::Utf8), processor: Processor::new(), term: new_term(100, 30, shared_backend.clone(), id, events.clone()), cols: 100, rows: 30, backend: shared_backend, + backend_events, scroll_pixel_y: 0.0, highlight_cache: std::cell::RefCell::new(None), } } pub fn feed(&mut self, bytes: &[u8]) { - self.processor.advance(&mut self.term, bytes); + let decoded = self.output_decoder.decode(bytes); + self.processor.advance(&mut self.term, &decoded); + } + + pub(crate) fn text_encoding(&self) -> TextEncoding { + self.text_encoding + } + + pub(crate) fn set_text_encoding(&mut self, encoding: TextEncoding) { + if self.text_encoding == encoding { + return; + } + self.text_encoding = encoding; + self.output_decoder = StreamingDecoder::new(encoding); + if let Some(session) = self.session.as_mut() { + session.terminal_encoding = encoding; + } + } + + pub(crate) fn encode_input(&self, bytes: &[u8]) -> Vec { + self.text_encoding.encode_terminal_input(bytes).into_owned() } /// Send a command to the backend. Thread-safe via the shared Arc. @@ -302,6 +649,15 @@ impl TerminalTab { } } + /// Advances this tab to a new backend generation and returns its sender. + /// Call this before closing the old backend so all of its remaining events + /// are discarded immediately. + pub fn advance_backend_events(&mut self) -> GuardedBackendEventSender { + let next = self.backend_events.next_generation(); + self.backend_events = next.clone(); + next + } + pub fn resize(&mut self, cols: u16, rows: u16) { let new_cols = cols.max(1); let new_rows = rows.max(1); @@ -343,6 +699,10 @@ impl TerminalTab { self.term.mode().contains(TermMode::APP_CURSOR) } + pub fn is_alternate_screen_active(&self) -> bool { + self.term.mode().contains(TermMode::ALT_SCREEN) + } + pub fn render_snapshot(&self, keyword_highlight: bool) -> RenderSnapshot { let rows = self.rows; let cols = self.cols; @@ -510,7 +870,7 @@ impl TerminalTab { if bracketed { bytes.extend_from_slice(b"\x1b[200~"); } - bytes.extend_from_slice(paste_text.as_bytes()); + bytes.extend_from_slice(&self.encode_input(paste_text.as_bytes())); if bracketed { bytes.extend_from_slice(b"\x1b[201~"); } diff --git a/src/text_encoding.rs b/src/text_encoding.rs new file mode 100644 index 0000000..4ebdc6b --- /dev/null +++ b/src/text_encoding.rs @@ -0,0 +1,218 @@ +use std::borrow::Cow; + +use encoding_rs::{ + BIG5, CoderResult, EUC_KR, Encoding, GB18030, GBK, SHIFT_JIS, UTF_8, UTF_16BE, UTF_16LE, + WINDOWS_1252, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TextEncoding { + #[default] + Utf8, + Gb18030, + Gbk, + Big5, + ShiftJis, + EucKr, + Windows1252, + Utf16Le, + Utf16Be, +} + +pub(crate) const TERMINAL_ENCODINGS: &[TextEncoding] = &[ + TextEncoding::Utf8, + TextEncoding::Gb18030, + TextEncoding::Gbk, + TextEncoding::Big5, + TextEncoding::ShiftJis, + TextEncoding::EucKr, + TextEncoding::Windows1252, +]; + +pub(crate) const FILE_ENCODINGS: &[TextEncoding] = &[ + TextEncoding::Utf8, + TextEncoding::Utf16Le, + TextEncoding::Utf16Be, + TextEncoding::Gb18030, + TextEncoding::Gbk, + TextEncoding::Big5, + TextEncoding::ShiftJis, + TextEncoding::EucKr, + TextEncoding::Windows1252, +]; + +impl TextEncoding { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Utf8 => "UTF-8", + Self::Gb18030 => "GB18030", + Self::Gbk => "GBK", + Self::Big5 => "Big5", + Self::ShiftJis => "Shift_JIS", + Self::EucKr => "EUC-KR", + Self::Windows1252 => "Windows-1252", + Self::Utf16Le => "UTF-16 LE", + Self::Utf16Be => "UTF-16 BE", + } + } + + pub(crate) fn encoding(self) -> &'static Encoding { + match self { + Self::Utf8 => UTF_8, + Self::Gb18030 => GB18030, + Self::Gbk => GBK, + Self::Big5 => BIG5, + Self::ShiftJis => SHIFT_JIS, + Self::EucKr => EUC_KR, + Self::Windows1252 => WINDOWS_1252, + Self::Utf16Le => UTF_16LE, + Self::Utf16Be => UTF_16BE, + } + } + + pub(crate) fn detect_bom(bytes: &[u8]) -> Option { + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + Some(Self::Utf8) + } else if bytes.starts_with(&[0xFF, 0xFE]) { + Some(Self::Utf16Le) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + Some(Self::Utf16Be) + } else { + None + } + } + + pub(crate) fn decode_file(self, bytes: &[u8]) -> (String, bool, bool) { + let bom_len = self.matching_bom_len(bytes); + let (text, had_errors) = self + .encoding() + .decode_without_bom_handling(&bytes[bom_len..]); + (text.into_owned(), had_errors, bom_len > 0) + } + + pub(crate) fn encode_file(self, text: &str, with_bom: bool) -> (Vec, bool) { + if matches!(self, Self::Utf16Le | Self::Utf16Be) { + let mut bytes = Vec::with_capacity(text.len().saturating_mul(2).saturating_add(2)); + if with_bom { + bytes.extend_from_slice(if self == Self::Utf16Le { + &[0xFF, 0xFE] + } else { + &[0xFE, 0xFF] + }); + } + for code_unit in text.encode_utf16() { + let encoded = if self == Self::Utf16Le { + code_unit.to_le_bytes() + } else { + code_unit.to_be_bytes() + }; + bytes.extend_from_slice(&encoded); + } + return (bytes, false); + } + + let (encoded, _, had_errors) = self.encoding().encode(text); + let mut bytes = Vec::with_capacity(encoded.len() + 3); + if with_bom { + match self { + Self::Utf8 => bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]), + Self::Utf16Le => bytes.extend_from_slice(&[0xFF, 0xFE]), + Self::Utf16Be => bytes.extend_from_slice(&[0xFE, 0xFF]), + _ => {} + } + } + bytes.extend_from_slice(&encoded); + (bytes, had_errors) + } + + pub(crate) fn default_bom(self) -> bool { + matches!(self, Self::Utf16Le | Self::Utf16Be) + } + + pub(crate) fn encode_terminal_input<'a>(self, bytes: &'a [u8]) -> Cow<'a, [u8]> { + if self == Self::Utf8 { + return Cow::Borrowed(bytes); + } + let Ok(text) = std::str::from_utf8(bytes) else { + return Cow::Borrowed(bytes); + }; + let (encoded, _, _) = self.encoding().encode(text); + encoded + } + + fn matching_bom_len(self, bytes: &[u8]) -> usize { + match self { + Self::Utf8 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => 3, + Self::Utf16Le if bytes.starts_with(&[0xFF, 0xFE]) => 2, + Self::Utf16Be if bytes.starts_with(&[0xFE, 0xFF]) => 2, + _ => 0, + } + } +} + +pub(crate) struct StreamingDecoder { + decoder: encoding_rs::Decoder, +} + +impl StreamingDecoder { + pub(crate) fn new(encoding: TextEncoding) -> Self { + Self { + decoder: encoding.encoding().new_decoder_without_bom_handling(), + } + } + + pub(crate) fn decode(&mut self, bytes: &[u8]) -> Vec { + let initial_capacity = self + .decoder + .max_utf8_buffer_length(bytes.len()) + .unwrap_or_else(|| bytes.len().saturating_mul(4).saturating_add(16)); + let mut output = String::with_capacity(initial_capacity); + let mut remaining = bytes; + + loop { + let (result, read, _) = self.decoder.decode_to_string(remaining, &mut output, false); + remaining = &remaining[read..]; + match result { + CoderResult::InputEmpty => break, + CoderResult::OutputFull => { + let additional = self + .decoder + .max_utf8_buffer_length(remaining.len()) + .unwrap_or_else(|| remaining.len().saturating_mul(4).saturating_add(16)) + .max(16); + output.reserve(additional); + } + } + } + + output.into_bytes() + } +} + +#[cfg(test)] +mod tests { + use super::{StreamingDecoder, TextEncoding}; + + #[test] + fn streaming_decoder_preserves_split_multibyte_characters() { + let (encoded, _) = TextEncoding::Gbk.encode_file("中文", false); + let mut decoder = StreamingDecoder::new(TextEncoding::Gbk); + let mut decoded = decoder.decode(&encoded[..1]); + decoded.extend(decoder.decode(&encoded[1..])); + + assert_eq!(String::from_utf8(decoded).unwrap(), "中文"); + } + + #[test] + fn file_encoding_preserves_matching_bom() { + let original = [0xFF, 0xFE, b'A', 0x00]; + let (text, had_errors, has_bom) = TextEncoding::Utf16Le.decode_file(&original); + let (encoded, encode_errors) = TextEncoding::Utf16Le.encode_file(&text, has_bom); + + assert!(!had_errors); + assert!(!encode_errors); + assert_eq!(encoded, original); + } +}