diff --git a/README.md b/README.md index 213cf04a..8c905ba8 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ R-Shell combines an interactive terminal, dual-panel file manager, remote deskto - **Terminal search** — Regex and case-sensitive search with F3 navigation - **Context menu** — Copy, paste, select all, clear, save to file, reconnect - **IME / CJK input** — Full support for Chinese, Japanese, Korean input methods +- **Working-directory follow** — Keep the remote file browser aligned with the active Bash terminal ### 🪟 Split Panes & Tab Groups diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b6c17fd7..021552eb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -535,7 +535,11 @@ pub async fn rename_file( .ok_or("Connection not found")?; let client = connection.read().await; - let command = format!("mv '{}' '{}'", shell_escape_single_quoted(&old_path), shell_escape_single_quoted(&new_path)); + let command = format!( + "mv '{}' '{}'", + shell_escape_single_quoted(&old_path), + shell_escape_single_quoted(&new_path) + ); match client.execute_command(&command).await { Ok(_) => Ok(true), @@ -611,7 +615,10 @@ pub async fn read_remote_file_base64( let client = connection.read().await; // Refuse very large files to avoid memory / performance issues - let size_cmd = format!("stat -c '%s' '{}' 2>/dev/null || stat -f '%z' '{}'", path, path); + let size_cmd = format!( + "stat -c '%s' '{}' 2>/dev/null || stat -f '%z' '{}'", + path, path + ); let size_str = client .execute_command(&size_cmd) .await @@ -639,11 +646,7 @@ pub async fn read_remote_file_base64( .map_err(|e| e.to_string())?; // Infer MIME type from extension - let ext = path - .rsplit('.') - .next() - .unwrap_or("") - .to_lowercase(); + let ext = path.rsplit('.').next().unwrap_or("").to_lowercase(); let mime = match ext.as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", @@ -2281,42 +2284,41 @@ pub async fn list_remote_files( } } -#[tauri::command] -pub async fn download_remote_file( - connection_id: String, - remote_path: String, - local_path: String, - state: State<'_, Arc>, +async fn download_remote_file_to_path( + connection_id: &str, + remote_path: &str, + local_path: &str, + state: &Arc, ) -> Result { - let conn_type = state.get_connection_type(&connection_id).await; + let conn_type = state.get_connection_type(connection_id).await; let result = match conn_type.as_deref() { Some("SFTP") => { let sftp_map = state.get_sftp_connection().await; let connections = sftp_map.read().await; let client = connections - .get(&connection_id) + .get(connection_id) .ok_or("SFTP connection not found".to_string())?; - client.download_file(&remote_path, &local_path).await + client.download_file(remote_path, local_path).await } Some("FTP") => { let ftp_map = state.get_ftp_connection().await; let mut connections = ftp_map.write().await; let client = connections - .get_mut(&connection_id) + .get_mut(connection_id) .ok_or("FTP connection not found".to_string())?; - client.download_file(&remote_path, &local_path).await + client.download_file(remote_path, local_path).await } Some(other) => return Err(format!("Unsupported protocol: {}", other)), None => { // Fallback: try SSH connection (integrated file browser uses SSH connections // which are not registered in connection_types) let connection = state - .get_connection(&connection_id) + .get_connection(connection_id) .await .ok_or_else(|| format!("No connection found for '{}'", connection_id))?; let client = connection.read().await; - client.download_file(&remote_path, &local_path).await + client.download_file(remote_path, local_path).await } }; @@ -2336,6 +2338,46 @@ pub async fn download_remote_file( } } +#[tauri::command] +pub async fn download_remote_file( + connection_id: String, + remote_path: String, + local_path: String, + state: State<'_, Arc>, +) -> Result { + download_remote_file_to_path(&connection_id, &remote_path, &local_path, state.inner()).await +} + +#[tauri::command] +pub async fn download_remote_file_confined( + connection_id: String, + remote_root: String, + destination_root: String, + remote_relative_path: String, + destination_relative_path: String, + state: State<'_, Arc>, +) -> Result { + validate_remote_relative_path(&remote_relative_path)?; + let local_path = resolve_confined_local_path( + std::path::Path::new(&destination_root), + &destination_relative_path, + )?; + let local_path = local_path + .to_str() + .ok_or_else(|| "Local destination path is not valid UTF-8".to_string())?; + let remote_path = if remote_root == "/" { + format!("/{}", remote_relative_path) + } else { + format!( + "{}/{}", + remote_root.trim_end_matches('/'), + remote_relative_path + ) + }; + + download_remote_file_to_path(&connection_id, &remote_path, local_path, state.inner()).await +} + #[tauri::command] pub async fn upload_remote_file( connection_id: String, @@ -2728,12 +2770,79 @@ pub async fn rename_local_item(old_path: String, new_path: String) -> Result<(), .map_err(|e| format!("Failed to rename '{}' to '{}': {}", old_path, new_path, e)) } +fn validate_remote_relative_path(remote_relative_path: &str) -> Result<(), String> { + if remote_relative_path.is_empty() + || remote_relative_path.starts_with('/') + || remote_relative_path.starts_with('\\') + || remote_relative_path.contains('\\') + { + return Err(format!( + "Unsafe remote relative path: {}", + remote_relative_path + )); + } + + for (index, component) in remote_relative_path.split('/').enumerate() { + let has_windows_prefix = index == 0 + && component + .as_bytes() + .get(1) + .is_some_and(|separator| *separator == b':'); + if component.is_empty() + || component == "." + || component == ".." + || component.contains('\0') + || has_windows_prefix + { + return Err(format!( + "Unsafe remote relative path: {}", + remote_relative_path + )); + } + } + Ok(()) +} + +fn resolve_confined_local_path( + destination_root: &std::path::Path, + remote_relative_path: &str, +) -> Result { + if !destination_root.is_absolute() { + return Err("Destination root must be absolute".to_string()); + } + validate_remote_relative_path(remote_relative_path)?; + + let mut resolved = destination_root.to_path_buf(); + for component in remote_relative_path.split('/') { + resolved.push(component); + } + + if !resolved.starts_with(destination_root) { + return Err(format!( + "Remote path escapes destination root: {}", + remote_relative_path + )); + } + Ok(resolved) +} + #[tauri::command] pub async fn create_local_directory(path: String) -> Result<(), String> { use std::fs; fs::create_dir_all(&path).map_err(|e| format!("Failed to create directory '{}': {}", path, e)) } +#[tauri::command] +pub async fn create_local_directory_confined( + destination_root: String, + relative_path: String, +) -> Result<(), String> { + let path = + resolve_confined_local_path(std::path::Path::new(&destination_root), &relative_path)?; + std::fs::create_dir_all(&path) + .map_err(|e| format!("Failed to create directory '{}': {}", path.display(), e)) +} + #[tauri::command] pub async fn open_in_os(path: String) -> Result<(), String> { open::that(&path).map_err(|e| format!("Failed to open '{}': {}", path, e)) @@ -2904,7 +3013,50 @@ pub async fn list_local_files_recursive( Ok(results) } -/// Recursively list all files/dirs under a remote directory (SFTP/FTP). +fn walk_sftp<'a>( + sftp: &'a russh_sftp::client::SftpSession, + base: &'a str, + current: &'a str, + exclude: &'a [String], + results: &'a mut Vec, +) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + let entries = crate::sftp_client::list_sftp_dir(sftp, current) + .await + .map_err(|e| e.to_string())?; + for entry in entries { + if matches_exclude(&entry.name, exclude) { + continue; + } + let full_path = if current == "/" { + format!("/{}", entry.name) + } else { + format!("{}/{}", current, entry.name) + }; + let relative_path = full_path + .strip_prefix(base) + .unwrap_or(&full_path) + .trim_start_matches('/') + .to_string(); + let is_dir = matches!(entry.file_type, FileEntryType::Directory); + + results.push(SyncFileEntry { + relative_path, + name: entry.name, + size: entry.size, + modified: entry.modified, + file_type: entry.file_type, + }); + + if is_dir { + walk_sftp(sftp, base, &full_path, exclude, results).await?; + } + } + Ok(()) + }) +} + +/// Recursively list all files/dirs under a remote directory (SSH/SFTP/FTP). #[tauri::command] pub async fn list_remote_files_recursive( connection_id: String, @@ -2912,67 +3064,21 @@ pub async fn list_remote_files_recursive( exclude_patterns: Vec, state: State<'_, Arc>, ) -> Result, String> { - let conn_type = state - .get_connection_type(&connection_id) - .await - .ok_or_else(|| format!("No file connection found for '{}'", connection_id))?; + let conn_type = state.get_connection_type(&connection_id).await; let mut results = Vec::new(); - match conn_type.as_str() { - "SFTP" => { + match conn_type.as_deref() { + Some("SFTP") => { let sftp_map = state.get_sftp_connection().await; let connections = sftp_map.read().await; let client = connections .get(&connection_id) .ok_or("SFTP connection not found")?; - - fn walk_sftp<'a>( - client: &'a crate::sftp_client::StandaloneSftpClient, - base: &'a str, - current: &'a str, - exclude: &'a [String], - results: &'a mut Vec, - ) -> std::pin::Pin> + Send + 'a>> - { - Box::pin(async move { - let entries = client.list_dir(current).await.map_err(|e| e.to_string())?; - for entry in entries { - if matches_exclude(&entry.name, exclude) { - continue; - } - let full_path = if current == "/" { - format!("/{}", entry.name) - } else { - format!("{}/{}", current, entry.name) - }; - let rel = full_path - .strip_prefix(base) - .unwrap_or(&full_path) - .trim_start_matches('/') - .to_string(); - - let is_dir = matches!(entry.file_type, FileEntryType::Directory); - - results.push(SyncFileEntry { - relative_path: rel.clone(), - name: entry.name.clone(), - size: entry.size, - modified: entry.modified.clone(), - file_type: entry.file_type.clone(), - }); - - if is_dir { - walk_sftp(client, base, &full_path, exclude, results).await?; - } - } - Ok(()) - }) - } - - walk_sftp(client, &path, &path, &exclude_patterns, &mut results).await?; + let sftp = client.sftp_session().map_err(|e| e.to_string())?; + walk_sftp(sftp, &path, &path, &exclude_patterns, &mut results).await?; } - "FTP" => { + Some("FTP") => { let ftp_map = state.get_ftp_connection().await; let mut connections = ftp_map.write().await; let client = connections @@ -3014,7 +3120,19 @@ pub async fn list_remote_files_recursive( } } } - _ => return Err(format!("Unsupported protocol: {}", conn_type)), + None | Some("SSH") => { + let connection = state + .get_connection(&connection_id) + .await + .ok_or("SSH connection not found")?; + let client = connection.read().await; + let sftp = client + .open_sftp_session() + .await + .map_err(|e| e.to_string())?; + walk_sftp(&sftp, &path, &path, &exclude_patterns, &mut results).await?; + } + Some(other) => return Err(format!("Unsupported protocol: {}", other)), } // Sort similarly @@ -3291,6 +3409,90 @@ mod local_fs_tests { assert!(std::path::Path::new(&new_dir).is_dir()); } + #[tokio::test] + async fn confined_directory_creation_stays_under_destination_root() { + let dir = TempDir::new().unwrap(); + let root = dir.path().to_string_lossy().to_string(); + + create_local_directory_confined(root, "nested/子目录".to_string()) + .await + .unwrap(); + + assert!(dir.path().join("nested").join("子目录").is_dir()); + } + + #[tokio::test] + async fn confined_directory_creation_rejects_windows_traversal() { + let dir = TempDir::new().unwrap(); + let root = dir.path().join("destination"); + fs::create_dir(&root).unwrap(); + + let result = create_local_directory_confined( + root.to_string_lossy().to_string(), + "nested\\..\\outside".to_string(), + ) + .await; + + assert!(result.is_err()); + assert!(!dir.path().join("outside").exists()); + } + + #[test] + fn confined_local_path_accepts_portable_nested_paths() { + let dir = TempDir::new().unwrap(); + + let resolved = resolve_confined_local_path(dir.path(), "子目录/report 1.txt").unwrap(); + + assert_eq!(resolved, dir.path().join("子目录").join("report 1.txt")); + } + + #[test] + fn confined_local_path_rejects_escaping_or_ambiguous_paths() { + let dir = TempDir::new().unwrap(); + let unsafe_paths = [ + "", + ".", + "../escape.txt", + "nested/../../escape.txt", + "/absolute.txt", + "\\absolute.txt", + "nested//file.txt", + "nested/./file.txt", + "C:/escape.txt", + "C:\\escape.txt", + "\\\\server\\share\\escape.txt", + "nested\\..\\escape.txt", + ]; + + for relative_path in unsafe_paths { + assert!( + resolve_confined_local_path(dir.path(), relative_path).is_err(), + "unsafe path should be rejected: {relative_path:?}" + ); + } + } + + #[test] + fn confined_local_path_allows_dots_within_normal_names() { + let dir = TempDir::new().unwrap(); + + for relative_path in ["report..txt", "..hidden", "nested/v1..2.txt"] { + assert!( + resolve_confined_local_path(dir.path(), relative_path).is_ok(), + "normal name should be accepted: {relative_path:?}" + ); + } + } + + #[test] + fn confined_local_path_requires_an_absolute_root() { + assert!(resolve_confined_local_path( + std::path::Path::new("relative-root"), + "nested/file.txt" + ) + .is_err()); + } + #[tokio::test] async fn test_list_local_files_recursive_returns_portable_relative_paths() { let dir = create_test_dir(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index faf3eac3..b9139a0c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -133,14 +133,38 @@ fn build_app_menu String>( &t("menuBar.connection"), true, &[ - &MenuItem::with_id(app, "new_tab", &t("menuBar.newTab"), true, Some("CmdOrCtrl+T"))?, - &MenuItem::with_id(app, "clone_tab", &t("menuBar.duplicateTab"), true, Some("CmdOrCtrl+D"))?, + &MenuItem::with_id( + app, + "new_tab", + &t("menuBar.newTab"), + true, + Some("CmdOrCtrl+T"), + )?, + &MenuItem::with_id( + app, + "clone_tab", + &t("menuBar.duplicateTab"), + true, + Some("CmdOrCtrl+D"), + )?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "next_tab", &t("menuBar.nextTab"), true, None::<&str>)?, - &MenuItem::with_id(app, "prev_tab", &t("menuBar.previousTab"), true, None::<&str>)?, + &MenuItem::with_id( + app, + "prev_tab", + &t("menuBar.previousTab"), + true, + None::<&str>, + )?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "reconnect", &t("menuBar.reconnect"), true, Some("F5"))?, - &MenuItem::with_id(app, "disconnect", &t("menuBar.disconnect"), true, None::<&str>)?, + &MenuItem::with_id( + app, + "disconnect", + &t("menuBar.disconnect"), + true, + None::<&str>, + )?, ], )?; @@ -294,6 +318,7 @@ pub fn run() { // Unified file operation commands commands::list_remote_files, commands::download_remote_file, + commands::download_remote_file_confined, commands::upload_remote_file, commands::delete_remote_item, commands::create_remote_directory, @@ -304,6 +329,7 @@ pub fn run() { commands::delete_local_item, commands::rename_local_item, commands::create_local_directory, + commands::create_local_directory_confined, commands::open_in_os, commands::stat_local_path, // Directory synchronization commands diff --git a/src-tauri/src/sftp_client.rs b/src-tauri/src/sftp_client.rs index 85f20193..3612f7e8 100644 --- a/src-tauri/src/sftp_client.rs +++ b/src-tauri/src/sftp_client.rs @@ -51,6 +51,48 @@ pub enum FileEntryType { Symlink, } +pub(crate) async fn list_sftp_dir(sftp: &SftpSession, path: &str) -> Result> { + let entries = sftp + .read_dir(path) + .await + .map_err(|e| anyhow::anyhow!("Failed to list directory '{}': {}", path, e))?; + + let mut result = Vec::new(); + for entry in entries { + let name = entry.file_name(); + if name == "." || name == ".." { + continue; + } + + let attrs = entry.metadata(); + let file_type = if attrs.is_dir() { + FileEntryType::Directory + } else if attrs.is_symlink() { + FileEntryType::Symlink + } else { + FileEntryType::File + }; + + result.push(RemoteFileEntry { + name, + size: attrs.size.unwrap_or(0), + modified: attrs.mtime.map(|t| chrono_from_unix_timestamp(t as u64)), + permissions: attrs.permissions.map(format_permissions), + file_type, + }); + } + + result.sort_by(|a, b| { + let a_is_dir = matches!(a.file_type, FileEntryType::Directory); + let b_is_dir = matches!(b.file_type, FileEntryType::Directory); + b_is_dir + .cmp(&a_is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + Ok(result) +} + /// Standalone SFTP client — opens an SSH connection and SFTP subsystem /// channel without allocating a PTY. pub struct StandaloneSftpClient { @@ -196,59 +238,15 @@ impl StandaloneSftpClient { // ===== File Operations ===== - /// List directory contents at `path`. - pub async fn list_dir(&self, path: &str) -> Result> { - let sftp = self - .sftp + pub(crate) fn sftp_session(&self) -> Result<&SftpSession> { + self.sftp .as_ref() - .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?; - - let entries = sftp - .read_dir(path) - .await - .map_err(|e| anyhow::anyhow!("Failed to list directory '{}': {}", path, e))?; - - let mut result = Vec::new(); - for entry in entries { - let name = entry.file_name(); - // Skip . and .. entries - if name == "." || name == ".." { - continue; - } - - let attrs = entry.metadata(); - let size = attrs.size.unwrap_or(0); - let modified = attrs.mtime.map(|t| chrono_from_unix_timestamp(t as u64)); - - let permissions = attrs.permissions.map(|p| format_permissions(p)); - - let file_type = if attrs.is_dir() { - FileEntryType::Directory - } else if attrs.is_symlink() { - FileEntryType::Symlink - } else { - FileEntryType::File - }; - - result.push(RemoteFileEntry { - name, - size, - modified, - permissions, - file_type, - }); - } - - // Sort: directories first, then by name - result.sort_by(|a, b| { - let a_is_dir = matches!(a.file_type, FileEntryType::Directory); - let b_is_dir = matches!(b.file_type, FileEntryType::Directory); - b_is_dir - .cmp(&a_is_dir) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); + .ok_or_else(|| anyhow::anyhow!("SFTP session not connected")) + } - Ok(result) + /// List directory contents at `path`. + pub async fn list_dir(&self, path: &str) -> Result> { + list_sftp_dir(self.sftp_session()?, path).await } /// Download a remote file to a local path. Returns bytes downloaded. diff --git a/src-tauri/src/ssh/mod.rs b/src-tauri/src/ssh/mod.rs index d9388aa0..32898ec9 100644 --- a/src-tauri/src/ssh/mod.rs +++ b/src-tauri/src/ssh/mod.rs @@ -23,6 +23,39 @@ pub static PREFERRED_HOST_KEY_ALGOS: &[russh_keys::key::Name] = &[ russh_keys::key::SSH_RSA, ]; +const BASH_VERSION_PROBE: &str = r#"printf '__RSHELL_BASH_VERSION__%s' "${BASH_VERSION-}""#; +const BASH_VERSION_MARKER: &str = "__RSHELL_BASH_VERSION__"; +const BASH_SHELL_INTEGRATION_PREFIX: &str = r#" stty echo; __rshell_report_cwd(){ local p=${PWD//%/%25}; p=${p// /%20}; p=${p//#/%23}; p=${p//\?/%3F}; printf '\033]7;file://%s%s\033\\' "${HOSTNAME:-localhost}" "$p"; }; "#; +const BASH_SHELL_INTEGRATION_SUFFIX: &str = "printf '\\r\\033[2K'\n"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct BashVersion { + pub(crate) major: u32, + pub(crate) minor: u32, +} + +pub(crate) fn bash_version_from_probe(output: &str) -> Option { + let version = output.rsplit_once(BASH_VERSION_MARKER)?.1.trim(); + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + Some(BashVersion { major, minor }) +} + +pub(crate) fn bash_shell_integration_command(version: BashVersion) -> Vec { + let prompt_command = if version >= (BashVersion { major: 5, minor: 1 }) { + r#"if declare -p PROMPT_COMMAND &>/dev/null; then PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" __rshell_report_cwd); else PROMPT_COMMAND=(__rshell_report_cwd); fi; "# + } else { + r#"if [[ -n ${PROMPT_COMMAND-} ]]; then PROMPT_COMMAND+=$'\n__rshell_report_cwd'; else PROMPT_COMMAND=__rshell_report_cwd; fi; "# + }; + + format!( + "{}{}{}", + BASH_SHELL_INTEGRATION_PREFIX, prompt_command, BASH_SHELL_INTEGRATION_SUFFIX + ) + .into_bytes() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SshConfig { pub host: String, @@ -264,8 +297,23 @@ impl SshClient { /// This enables interactive commands like vim, less, more, top, etc. pub async fn create_pty_session(&self, cols: u32, rows: u32) -> Result { if let Some(session) = &self.session { + let bash_version = tokio::time::timeout( + Duration::from_secs(2), + self.execute_command(BASH_VERSION_PROBE), + ) + .await + .ok() + .and_then(Result::ok) + .and_then(|output| bash_version_from_probe(&output)); + // Open a new SSH channel let mut channel = session.channel_open_session().await?; + let bash_terminal_modes = [(Pty::ECHO, 0), (Pty::ECHONL, 0)]; + let terminal_modes = if bash_version.is_some() { + bash_terminal_modes.as_slice() + } else { + &[] + }; // Request PTY with terminal type and dimensions // Similar to ttyd's approach: xterm-256color terminal @@ -277,7 +325,7 @@ impl SshClient { rows, // rows 0, // pixel_width (not used) 0, // pixel_height (not used) - &[], // terminal modes + terminal_modes, ) .await?; @@ -292,7 +340,12 @@ impl SshClient { let channel_id = channel.id(); // Clone channel for input task - let input_channel = channel.make_writer(); + let mut input_channel = channel.make_writer(); + if let Some(version) = bash_version { + let integration_command = bash_shell_integration_command(version); + input_channel.write_all(&integration_command).await?; + input_channel.flush().await?; + } // Create a channel for resize requests let (resize_tx, mut resize_rx) = mpsc::channel::<(u32, u32)>(16); @@ -378,6 +431,16 @@ impl SshClient { } } + pub(crate) async fn open_sftp_session(&self) -> Result { + let session = self + .session + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Not connected"))?; + let channel = session.channel_open_session().await?; + channel.request_subsystem(true, "sftp").await?; + Ok(SftpSession::new(channel.into_stream()).await?) + } + pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result { if let Some(session) = &self.session { // Open SFTP subsystem diff --git a/src-tauri/src/ssh/tests.rs b/src-tauri/src/ssh/tests.rs index 9b824939..abce9d80 100644 --- a/src-tauri/src/ssh/tests.rs +++ b/src-tauri/src/ssh/tests.rs @@ -164,6 +164,172 @@ mod tests { } } +#[cfg(test)] +mod shell_integration_tests { + use crate::sftp_client::list_sftp_dir; + use crate::ssh::{ + bash_shell_integration_command, bash_version_from_probe, AuthMethod, BashVersion, + PtySession, SshClient, SshConfig, + }; + use std::time::Duration; + use tokio::time::{timeout, Instant}; + + #[test] + fn parses_major_and_minor_from_bash_probe_results() { + assert_eq!( + bash_version_from_probe("__RSHELL_BASH_VERSION__5.2.37(1)-release"), + Some(BashVersion { major: 5, minor: 2 }) + ); + assert_eq!( + bash_version_from_probe("profile output\n__RSHELL_BASH_VERSION__4.4.20(1)-release"), + Some(BashVersion { major: 4, minor: 4 }) + ); + assert_eq!( + bash_version_from_probe("__RSHELL_BASH_VERSION__5.1.0"), + Some(BashVersion { major: 5, minor: 1 }) + ); + } + + #[test] + fn rejects_missing_or_malformed_bash_probe_results() { + for output in [ + "__RSHELL_BASH_VERSION__", + "__RSHELL_BASH_VERSION__five.two", + "__RSHELL_BASH_VERSION__5", + "5.2.37", + ] { + assert_eq!(bash_version_from_probe(output), None, "output: {output:?}"); + } + } + + #[test] + fn uses_scalar_prompt_command_before_bash_5_1() { + for version in [ + BashVersion { major: 3, minor: 2 }, + BashVersion { major: 4, minor: 4 }, + BashVersion { major: 5, minor: 0 }, + ] { + let command = String::from_utf8(bash_shell_integration_command(version)).unwrap(); + assert!(command.contains("PROMPT_COMMAND+=$'\\n__rshell_report_cwd'")); + assert!(!command.contains("PROMPT_COMMAND=(\"${PROMPT_COMMAND[@]}\"")); + } + } + + #[test] + fn uses_prompt_command_array_from_bash_5_1() { + for version in [ + BashVersion { major: 5, minor: 1 }, + BashVersion { major: 5, minor: 2 }, + BashVersion { major: 6, minor: 0 }, + ] { + let command = String::from_utf8(bash_shell_integration_command(version)).unwrap(); + assert!( + command.contains("PROMPT_COMMAND=(\"${PROMPT_COMMAND[@]}\" __rshell_report_cwd)") + ); + } + } + + #[test] + fn shell_integration_restores_echo_and_emits_osc_7() { + for version in [ + BashVersion { major: 4, minor: 4 }, + BashVersion { major: 5, minor: 2 }, + ] { + let command = bash_shell_integration_command(version); + assert!(command.starts_with(b" stty echo;")); + assert!(!command + .windows(b"history -d".len()) + .any(|window| window == b"history -d")); + assert!(command + .windows(b"]7;file://".len()) + .any(|window| window == b"]7;file://")); + assert!(command.ends_with(b"\n")); + } + } + + async fn read_until(pty: &PtySession, needle: &[u8]) -> Vec { + let deadline = Instant::now() + Duration::from_secs(10); + let mut output = Vec::new(); + while !output.windows(needle.len()).any(|window| window == needle) { + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!(!remaining.is_zero(), "timed out waiting for PTY output"); + let chunk = timeout(remaining, async { pty.output_rx.lock().await.recv().await }) + .await + .expect("timed out waiting for PTY output") + .expect("PTY output channel closed"); + output.extend_from_slice(&chunk); + } + output + } + + async fn send_and_expect_cwd(pty: &PtySession, command: &str, expected_path: &str) { + let mut input = command.as_bytes().to_vec(); + input.push(b'\n'); + pty.input_tx.send(input).await.expect("send shell command"); + + let output = read_until(pty, b"\x1b\\").await; + assert!( + String::from_utf8_lossy(&output).contains(expected_path), + "OSC 7 output should contain {expected_path:?}" + ); + } + + #[tokio::test] + #[ignore] + async fn docker_ssh_reports_cwd_and_lists_sftp_directories() { + let mut client = SshClient::new(); + client + .connect(&SshConfig { + host: std::env::var("RSHELL_TEST_SSH_HOST") + .unwrap_or_else(|_| "rshell-test-ssh".to_string()), + port: 22, + username: "testuser".to_string(), + auth_method: AuthMethod::Password { + password: "testpass".to_string(), + }, + }) + .await + .expect("connect to Docker SSH server"); + + let pty = client.create_pty_session(80, 24).await.expect("create PTY"); + let initial_output = read_until(&pty, b"\x1b\\").await; + assert!( + String::from_utf8_lossy(&initial_output).contains("/home/testuser"), + "initial OSC 7 should report the login directory" + ); + + send_and_expect_cwd( + &pty, + "cd '/srv/release files/子目录'", + "/srv/release%20files/子目录", + ) + .await; + send_and_expect_cwd(&pty, "cd ..", "/srv/release%20files").await; + send_and_expect_cwd(&pty, "cd '子目录'", "/srv/release%20files/子目录").await; + send_and_expect_cwd(&pty, "cd -", "/srv/release%20files").await; + send_and_expect_cwd(&pty, "cd ~", "/home/testuser").await; + send_and_expect_cwd( + &pty, + "pushd '/srv/release files/子目录'", + "/srv/release%20files/子目录", + ) + .await; + send_and_expect_cwd(&pty, "popd", "/home/testuser").await; + + let sftp = client.open_sftp_session().await.expect("open SFTP"); + let root_entries = list_sftp_dir(&sftp, "/srv/release files") + .await + .expect("list directory over SFTP"); + assert!(root_entries.iter().any(|entry| entry.name == "子目录")); + let nested_entries = list_sftp_dir(&sftp, "/srv/release files/子目录") + .await + .expect("list nested directory over SFTP"); + assert!(nested_entries + .iter() + .any(|entry| entry.name == "report 1.txt")); + } +} + // ── Key-loading unit tests (no SSH server required) ────────────────────────── #[cfg(test)] @@ -256,7 +422,9 @@ mod key_loading_tests { let err = client.connect(&config).await.unwrap_err(); let msg = err.to_string(); assert!( - msg.contains("not found") || msg.contains("SSH key file") || msg.contains("Connection refused"), + msg.contains("not found") + || msg.contains("SSH key file") + || msg.contains("Connection refused"), "Error should mention the missing file, got: {msg}" ); } diff --git a/src/App.tsx b/src/App.tsx index 01bca809..a7649002 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { applyLanguageFromPreference } from './lib/i18n'; import { invoke } from '@tauri-apps/api/core'; @@ -57,6 +57,20 @@ function AppContent() { // Terminal group state from context const { state, dispatch, activeGroup, activeTab, activeConnection } = useTerminalGroups(); + const workingDirectorySequenceRef = useRef(0); + const [terminalWorkingDirectories, setTerminalWorkingDirectories] = useState< + Record + >({}); + + const handleWorkingDirectoryChange = useCallback((connectionId: string, path: string) => { + setTerminalWorkingDirectories((previous) => ({ + ...previous, + [connectionId]: { + path, + sequence: ++workingDirectorySequenceRef.current, + }, + })); + }, []); // Modal states const [connectionDialogOpen, setConnectionDialogOpen] = useState(false); @@ -1856,6 +1870,7 @@ function AppContent() { onNewTab: handleNewTab, onReconnectTab: handleReconnect, closeTabShortcut: keyboardShortcutSettings.closeTab, + onWorkingDirectoryChange: handleWorkingDirectoryChange, }}> @@ -1881,6 +1896,7 @@ function AppContent() { connectionId={activeConnection.connectionId} host={activeConnection.host} isConnected={activeConnection.status === 'connected'} + terminalWorkingDirectory={terminalWorkingDirectories[activeConnection.connectionId]} onClose={() => {}} onOpenInLogMonitor={handleOpenInLogMonitor} onOpenInEditor={handleOpenInEditor} diff --git a/src/__tests__/directory-transfer-dialog.test.tsx b/src/__tests__/directory-transfer-dialog.test.tsx new file mode 100644 index 00000000..f0eb706c --- /dev/null +++ b/src/__tests__/directory-transfer-dialog.test.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DirectoryTransferDialog } from '../components/directory-transfer-dialog'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mocks.invoke, +})); + +vi.mock('sonner', () => ({ + toast: { + success: mocks.success, + warning: mocks.warning, + error: mocks.error, + }, +})); + +vi.mock('../components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +describe('DirectoryTransferDialog download', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(cleanup); + + it('preserves nested directories, spaces, and Unicode', async () => { + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'list_remote_files_recursive') { + return [ + { + relative_path: '子目录', + name: '子目录', + size: 0, + modified: null, + file_type: 'Directory', + }, + { + relative_path: '子目录/report 1.txt', + name: 'report 1.txt', + size: 12, + modified: null, + file_type: 'File', + }, + { + relative_path: 'README.md', + name: 'README.md', + size: 5, + modified: null, + file_type: 'File', + }, + ]; + } + if (command === 'download_remote_file_confined') { + return { success: true, bytes_transferred: 1 }; + } + return undefined; + }); + const onComplete = vi.fn(); + + render( + {}} + direction="download" + connectionId="conn-1" + sourcePath="/srv/release files" + destPath="C:/Downloads" + destinationDirectoryName="release files" + onComplete={onComplete} + />, + ); + + await waitFor(() => expect(onComplete).toHaveBeenCalledOnce()); + + expect(mocks.invoke).toHaveBeenCalledWith('create_local_directory_confined', { + destinationRoot: 'C:/Downloads', + relativePath: 'release files', + }); + expect(mocks.invoke).toHaveBeenCalledWith('create_local_directory_confined', { + destinationRoot: 'C:/Downloads', + relativePath: 'release files/子目录', + }); + expect(mocks.invoke).toHaveBeenCalledWith('download_remote_file_confined', { + connectionId: 'conn-1', + remoteRoot: '/srv/release files', + destinationRoot: 'C:/Downloads', + remoteRelativePath: '子目录/report 1.txt', + destinationRelativePath: 'release files/子目录/report 1.txt', + }); + expect(mocks.invoke).toHaveBeenCalledWith('download_remote_file_confined', { + connectionId: 'conn-1', + remoteRoot: '/srv/release files', + destinationRoot: 'C:/Downloads', + remoteRelativePath: 'README.md', + destinationRelativePath: 'release files/README.md', + }); + expect(mocks.success).toHaveBeenCalledOnce(); + }); + + it('keeps remote relative paths separate from the local destination root', async () => { + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'list_remote_files_recursive') { + return [{ + relative_path: 'nested\\..\\outside.txt', + name: 'nested\\..\\outside.txt', + size: 1, + modified: null, + file_type: 'File', + }]; + } + if (command === 'download_remote_file_confined') { + throw new Error('Unsafe remote relative path'); + } + return undefined; + }); + + render( + {}} + direction="download" + connectionId="conn-1" + sourcePath="/srv/release" + destPath="C:/Downloads" + destinationDirectoryName="release" + onComplete={() => {}} + />, + ); + + await waitFor(() => expect(mocks.warning).toHaveBeenCalledOnce()); + expect(mocks.invoke).toHaveBeenCalledWith('download_remote_file_confined', { + connectionId: 'conn-1', + remoteRoot: '/srv/release', + destinationRoot: 'C:/Downloads', + remoteRelativePath: 'nested\\..\\outside.txt', + destinationRelativePath: 'release/nested\\..\\outside.txt', + }); + expect(mocks.invoke.mock.calls.some(([command]) => command === 'download_remote_file')).toBe(false); + expect(mocks.success).not.toHaveBeenCalled(); + }); + + it('stops before creating or downloading when enumeration fails', async () => { + mocks.invoke.mockRejectedValueOnce(new Error('permission denied')); + + render( + {}} + direction="download" + connectionId="conn-1" + sourcePath="/root/private" + destPath="C:/Downloads/private" + onComplete={() => {}} + />, + ); + + await waitFor(() => expect(mocks.error).toHaveBeenCalledOnce()); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(mocks.invoke).toHaveBeenCalledWith('list_remote_files_recursive', { + connectionId: 'conn-1', + path: '/root/private', + excludePatterns: [], + }); + }); + + it('cancels before transferring files while enumeration is pending', async () => { + let resolveEntries: ((entries: unknown[]) => void) | undefined; + mocks.invoke.mockImplementationOnce( + () => new Promise((resolve) => { + resolveEntries = resolve; + }), + ); + const onComplete = vi.fn(); + + render( + {}} + direction="download" + connectionId="conn-1" + sourcePath="/srv/release" + destPath="C:/Downloads/release" + onComplete={onComplete} + />, + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + await act(async () => { + resolveEntries?.([{ + relative_path: 'late.txt', + name: 'late.txt', + size: 1, + modified: null, + file_type: 'File', + }]); + }); + + expect(await screen.findByText('Cancelled')).toBeTruthy(); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/integrated-file-browser-keyboard.test.tsx b/src/__tests__/integrated-file-browser-keyboard.test.tsx index 56118dc1..4bd8f4dd 100644 --- a/src/__tests__/integrated-file-browser-keyboard.test.tsx +++ b/src/__tests__/integrated-file-browser-keyboard.test.tsx @@ -1,14 +1,20 @@ import React from 'react'; -import { cleanup, render } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IntegratedFileBrowser } from '../components/integrated-file-browser'; -vi.mock('@tauri-apps/api/core', () => ({ +const mocks = vi.hoisted(() => ({ invoke: vi.fn(), + open: vi.fn(), + warning: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mocks.invoke, })); vi.mock('@tauri-apps/plugin-dialog', () => ({ - open: vi.fn(), + open: mocks.open, save: vi.fn(), })); @@ -17,9 +23,15 @@ vi.mock('sonner', () => ({ error: vi.fn(), info: vi.fn(), success: vi.fn(), + warning: mocks.warning, }, })); +vi.mock('../lib/async-retry', () => ({ + CancelledError: class CancelledError extends Error {}, + withRetry: (operation: () => Promise) => operation(), +})); + vi.mock('../components/directory-tree', () => ({ DirectoryTree: () =>
, })); @@ -28,12 +40,42 @@ vi.mock('../components/transfer-queue', () => ({ TransferQueue: () => null, })); +vi.mock('../components/directory-transfer-dialog', () => ({ + DirectoryTransferDialog: ({ + sourcePath, + destPath, + destinationDirectoryName, + }: { + sourcePath: string; + destPath: string; + destinationDirectoryName?: string; + }) =>
{sourcePath} → {destPath}/{destinationDirectoryName}
, +})); + +vi.mock('../components/ui/context-menu', () => ({ + ContextMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + ContextMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + ContextMenuItem: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), + ContextMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + ContextMenuSeparator: () =>
, +})); + vi.mock('../components/ui/resizable', () => ({ ResizableHandle: () =>
, ResizablePanel: ({ children }: { children: React.ReactNode }) =>
{children}
, ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, })); +beforeEach(() => { + localStorage.clear(); + mocks.invoke.mockReset(); + mocks.invoke.mockResolvedValue([]); + mocks.open.mockReset(); + mocks.warning.mockReset(); +}); + afterEach(() => { cleanup(); vi.restoreAllMocks(); @@ -67,3 +109,174 @@ describe('IntegratedFileBrowser keyboard shortcuts', () => { input.remove(); }); }); + +describe('IntegratedFileBrowser terminal directory following', () => { + it('loads the active terminal directory and decodes spaces and Unicode', async () => { + const terminalWorkingDirectory = { + path: '/srv/My Project/测试', + sequence: 1, + }; + + render( + {}} + terminalWorkingDirectory={terminalWorkingDirectory} + />, + ); + + await waitFor(() => { + expect(mocks.invoke).toHaveBeenCalledWith('list_files', { + connectionId: 'conn-1', + path: terminalWorkingDirectory.path, + }); + }); + expect(await screen.findByTitle(terminalWorkingDirectory.path)).toBeTruthy(); + }); + + it('can pause terminal directory following', async () => { + const { rerender } = render( + {}} + terminalWorkingDirectory={{ path: '/srv/first', sequence: 1 }} + />, + ); + + const followToggle = await screen.findByTitle('Follow terminal directory'); + expect(followToggle.getAttribute('aria-pressed')).toBe('true'); + expect(followToggle.getAttribute('data-state')).toBe('on'); + + fireEvent.click(followToggle); + + expect(followToggle.getAttribute('aria-pressed')).toBe('false'); + expect(followToggle.getAttribute('data-state')).toBe('off'); + expect(localStorage.getItem('rshell-follow-terminal-directory')).toBe('false'); + mocks.invoke.mockClear(); + + rerender( + {}} + terminalWorkingDirectory={{ path: '/srv/second', sequence: 2 }} + />, + ); + + await waitFor(() => { + expect(mocks.invoke).not.toHaveBeenCalledWith('list_files', { + connectionId: 'conn-1', + path: '/srv/second', + }); + }); + }); + + it('returns to the same terminal directory after manual navigation on the next prompt', async () => { + const { rerender } = render( + {}} + terminalWorkingDirectory={{ path: '/srv/app', sequence: 1 }} + />, + ); + + await waitFor(() => { + expect( + mocks.invoke.mock.calls.filter(([, args]) => args.path === '/srv/app'), + ).toHaveLength(1); + }); + fireEvent.click(screen.getByTitle('Home')); + await waitFor(() => { + expect(mocks.invoke).toHaveBeenCalledWith('list_files', { + connectionId: 'conn-manual', + path: '/home', + }); + }); + + rerender( + {}} + terminalWorkingDirectory={{ path: '/srv/app', sequence: 2 }} + />, + ); + + await waitFor(() => { + expect( + mocks.invoke.mock.calls.filter(([, args]) => args.path === '/srv/app'), + ).toHaveLength(2); + }); + }); + + it('keeps the last good directory and warns once when a terminal path is inaccessible', async () => { + mocks.invoke.mockImplementation(async (_command: string, args: { path: string }) => { + if (args.path === '/root/private') throw new Error('permission denied'); + return []; + }); + const { rerender } = render( + {}} + terminalWorkingDirectory={{ path: '/root/private', sequence: 1 }} + />, + ); + + await waitFor(() => expect(mocks.warning).toHaveBeenCalledOnce()); + expect(await screen.findByTitle('/home')).toBeTruthy(); + + rerender( + {}} + terminalWorkingDirectory={{ path: '/root/private', sequence: 2 }} + />, + ); + + await waitFor(() => { + expect( + mocks.invoke.mock.calls.filter(([, args]) => args.path === '/root/private'), + ).toHaveLength(2); + }); + expect(mocks.warning).toHaveBeenCalledOnce(); + expect(await screen.findByTitle('/home')).toBeTruthy(); + }); +}); + +describe('IntegratedFileBrowser directory download', () => { + it('opens the recursive transfer dialog for a remote directory', async () => { + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'list_files') { + return [{ + name: 'release files', + size: 0, + modified: null, + permissions: 'drwxr-xr-x', + file_type: 'Directory', + }]; + } + return undefined; + }); + mocks.open.mockResolvedValue('C:/Downloads'); + + render( + {}} + />, + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Download directory' })); + + expect(mocks.open).toHaveBeenCalledWith({ directory: true }); + expect((await screen.findByTestId('directory-transfer')).textContent).toBe( + '/home/release files → C:/Downloads/release files', + ); + }); +}); diff --git a/src/__tests__/pty-terminal-activation.test.tsx b/src/__tests__/pty-terminal-activation.test.tsx index 25d6699a..8ee389a6 100644 --- a/src/__tests__/pty-terminal-activation.test.tsx +++ b/src/__tests__/pty-terminal-activation.test.tsx @@ -7,6 +7,9 @@ const mocks = vi.hoisted(() => { const terminals: Array = []; const fitAddons: Array = []; const webSockets: Array = []; + const terminalCallbacks = { + onWorkingDirectoryChange: vi.fn(), + }; class MockTerminal { cols = 80; @@ -18,6 +21,13 @@ const mocks = vi.hoisted(() => { getLine: vi.fn(), }, }; + oscHandlers = new Map boolean | Promise>(); + parser = { + registerOscHandler: vi.fn((identifier: number, handler: (data: string) => boolean | Promise) => { + this.oscHandlers.set(identifier, handler); + return { dispose: vi.fn() }; + }), + }; loadAddon = vi.fn(); open = vi.fn(); @@ -70,7 +80,7 @@ const mocks = vi.hoisted(() => { return terminal; }); - return { terminals, fitAddons, webSockets, Terminal, MockFitAddon, MockWebSocket }; + return { terminals, fitAddons, webSockets, terminalCallbacks, Terminal, MockFitAddon, MockWebSocket }; }); vi.mock('@xterm/xterm', () => ({ @@ -152,7 +162,7 @@ vi.mock('../lib/restoration-manager', () => ({ })); vi.mock('../lib/terminal-callbacks-context', () => ({ - useTerminalCallbacks: () => ({}), + useTerminalCallbacks: () => mocks.terminalCallbacks, })); vi.mock('sonner', () => ({ @@ -201,6 +211,7 @@ describe('PtyTerminal activation', () => { mocks.terminals.length = 0; mocks.fitAddons.length = 0; mocks.webSockets.length = 0; + mocks.terminalCallbacks.onWorkingDirectoryChange.mockClear(); Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, @@ -240,6 +251,14 @@ describe('PtyTerminal activation', () => { expect(mocks.terminals[0].focus).not.toHaveBeenCalled(); }); + it('reports OSC 7 working-directory changes for its own connection', () => { + renderTerminal(true); + + expect(mocks.terminals[0].oscHandlers.get(7)?.('file://server/srv/app')).toBe(true); + expect(mocks.terminalCallbacks.onWorkingDirectoryChange) + .toHaveBeenCalledWith('connection-1', '/srv/app'); + }); + it('fits, refreshes, and focuses the terminal when it becomes active', async () => { const { rerender } = renderTerminal(false); const terminal = mocks.terminals[0]; diff --git a/src/__tests__/pty-terminal-scrollbar.test.tsx b/src/__tests__/pty-terminal-scrollbar.test.tsx index 0f818fed..224f1338 100644 --- a/src/__tests__/pty-terminal-scrollbar.test.tsx +++ b/src/__tests__/pty-terminal-scrollbar.test.tsx @@ -17,6 +17,9 @@ const mocks = vi.hoisted(() => { getLine: vi.fn(), }, }; + parser = { + registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })), + }; loadAddon = vi.fn(); open = vi.fn(); diff --git a/src/__tests__/sync-dialog.test.tsx b/src/__tests__/sync-dialog.test.tsx new file mode 100644 index 00000000..cc659bba --- /dev/null +++ b/src/__tests__/sync-dialog.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SyncDialog } from '../components/sync-dialog'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); +vi.mock('../components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../components/ui/select', () => ({ + Select: ({ children, onValueChange }: { children: React.ReactNode; onValueChange: (value: string) => void }) => ( +
+ + {children} +
+ ), + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectValue: () => null, +})); + +describe('SyncDialog remote downloads', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'list_local_files_recursive') { + return [{ + relative_path: 'nested/report.txt', + name: 'report.txt', + size: 1, + modified: null, + file_type: 'File', + }]; + } + if (command === 'list_remote_files_recursive') { + return [{ + relative_path: 'nested/report.txt', + name: 'report.txt', + size: 12, + modified: null, + file_type: 'File', + }]; + } + if (command === 'download_remote_file_confined') return { success: true }; + return undefined; + }); + }); + + afterEach(cleanup); + + it('keeps the remote relative path separate from the local root', async () => { + render( + {}} + connectionId="conn-1" + localPath="C:/Downloads/release" + remotePath="/srv/release" + onLoadLocalDir={async () => []} + onLoadRemoteDir={async () => []} + onCreateRemoteDir={async () => {}} + onDeleteRemoteItem={async () => {}} + onSyncComplete={() => {}} + />, + ); + + fireEvent.click(screen.getAllByRole('button', { name: 'Set remote to local' })[0]); + fireEvent.click(screen.getByRole('button', { name: 'Compare' })); + fireEvent.click(await screen.findByRole('button', { name: 'Sync (1 items)' })); + + await waitFor(() => { + expect(mocks.invoke).toHaveBeenCalledWith('download_remote_file_confined', { + connectionId: 'conn-1', + remoteRoot: '/srv/release', + destinationRoot: 'C:/Downloads/release', + remoteRelativePath: 'nested/report.txt', + destinationRelativePath: 'nested/report.txt', + }); + }); + expect(mocks.invoke.mock.calls.some(([command]) => command === 'download_remote_file')).toBe(false); + }); +}); diff --git a/src/components/directory-transfer-dialog.tsx b/src/components/directory-transfer-dialog.tsx index 8ff16b37..2b802df6 100644 --- a/src/components/directory-transfer-dialog.tsx +++ b/src/components/directory-transfer-dialog.tsx @@ -47,8 +47,10 @@ export interface DirectoryTransferDialogProps { connectionId: string; /** Full path to the source directory */ sourcePath: string; - /** Full path to the destination directory (will be created) */ + /** Full path to the destination root directory */ destPath: string; + /** Optional child directory created below the destination root */ + destinationDirectoryName?: string; /** Called when transfer completes to refresh panels */ onComplete: () => void; } @@ -85,6 +87,7 @@ export function DirectoryTransferDialog({ connectionId, sourcePath, destPath, + destinationDirectoryName, onComplete, }: DirectoryTransferDialogProps) { const { t } = useTranslation(); @@ -156,6 +159,12 @@ export function DirectoryTransferDialog({ totalBytes, })); + const destinationPrefix = destinationDirectoryName + ? `${destinationDirectoryName}/` + : ""; + const destinationRelativePath = (relativePath: string) => + `${destinationPrefix}${relativePath}`; + // Phase 2: Create directory structure // Sort dirs by path depth so parents come first const sortedDirs = [...dirs].sort( @@ -172,6 +181,15 @@ export function DirectoryTransferDialog({ } catch { // May already exist, continue } + } else if (destinationDirectoryName) { + try { + await invoke("create_local_directory_confined", { + destinationRoot: destPath, + relativePath: destinationDirectoryName, + }); + } catch { + // May already exist, continue + } } else { try { await invoke("create_local_directory", { path: destPath }); @@ -187,11 +205,6 @@ export function DirectoryTransferDialog({ return; } - const dirDestPath = - destPath === "/" - ? `/${dir.relative_path}` - : `${destPath}/${dir.relative_path}`; - setProgress((p) => ({ ...p, currentItem: dir.relative_path, @@ -200,13 +213,18 @@ export function DirectoryTransferDialog({ try { if (direction === "upload") { + const dirDestPath = + destPath === "/" + ? `/${dir.relative_path}` + : `${destPath}/${dir.relative_path}`; await invoke<{ success: boolean; error?: string }>( "create_remote_directory", { connectionId, path: dirDestPath }, ); } else { - await invoke("create_local_directory", { - path: dirDestPath, + await invoke("create_local_directory_confined", { + destinationRoot: destPath, + relativePath: destinationRelativePath(dir.relative_path), }); } } catch (err) { @@ -238,10 +256,6 @@ export function DirectoryTransferDialog({ sourcePath === "/" ? `/${file.relative_path}` : `${sourcePath}/${file.relative_path}`; - const fileDestPath = - destPath === "/" - ? `/${file.relative_path}` - : `${destPath}/${file.relative_path}`; setProgress((p) => ({ ...p, @@ -252,6 +266,10 @@ export function DirectoryTransferDialog({ try { if (direction === "upload") { + const fileDestPath = + destPath === "/" + ? `/${file.relative_path}` + : `${destPath}/${file.relative_path}`; const result = await invoke<{ success: boolean; error?: string; @@ -267,10 +285,12 @@ export function DirectoryTransferDialog({ const result = await invoke<{ success: boolean; error?: string; - }>("download_remote_file", { + }>("download_remote_file_confined", { connectionId, - remotePath: fileSrcPath, - localPath: fileDestPath, + remoteRoot: sourcePath, + destinationRoot: destPath, + remoteRelativePath: file.relative_path, + destinationRelativePath: destinationRelativePath(file.relative_path), }); if (!result.success) { throw new Error(result.error ?? "Download failed"); @@ -332,7 +352,7 @@ export function DirectoryTransferDialog({ description: err instanceof Error ? err.message : String(err), }); } - }, [direction, connectionId, sourcePath, destPath, onComplete]); + }, [direction, connectionId, sourcePath, destPath, destinationDirectoryName, onComplete]); const isBusy = progress.phase === "enumerating" || progress.phase === "transferring"; diff --git a/src/components/file-browser-view.tsx b/src/components/file-browser-view.tsx index 93acdc6d..5fce5f39 100644 --- a/src/components/file-browser-view.tsx +++ b/src/components/file-browser-view.tsx @@ -63,6 +63,7 @@ export function FileBrowserView({ direction: "upload" | "download"; sourcePath: string; destPath: string; + destinationDirectoryName?: string; } | null>(null); const [localHomePath, setLocalHomePath] = useState( undefined, @@ -460,7 +461,8 @@ export function FileBrowserView({ open: true, direction: "download", sourcePath: pathJoin(sourceDirPath, dirName), - destPath: pathJoin(localPath, dirName), + destPath: localPath, + destinationDirectoryName: dirName, }); }, [], @@ -662,6 +664,7 @@ export function FileBrowserView({ connectionId={connectionId} sourcePath={dirTransfer.sourcePath} destPath={dirTransfer.destPath} + destinationDirectoryName={dirTransfer.destinationDirectoryName} onComplete={handleDirTransferComplete} /> )} diff --git a/src/components/integrated-file-browser.tsx b/src/components/integrated-file-browser.tsx index 83974b13..a4372369 100644 --- a/src/components/integrated-file-browser.tsx +++ b/src/components/integrated-file-browser.tsx @@ -5,6 +5,7 @@ import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-ma import { save, open as tauriOpen } from '@tauri-apps/plugin-dialog'; import { withRetry, CancelledError } from '@/lib/async-retry'; import { Button } from './ui/button'; +import { Toggle } from './ui/toggle'; import { Input } from './ui/input'; import { ScrollArea } from './ui/scroll-area'; import { @@ -22,6 +23,7 @@ import { } from '@/lib/upload-paths'; import { useWebviewFileDrop } from '@/lib/use-webview-file-drop'; import { TransferQueue } from './transfer-queue'; +import { DirectoryTransferDialog } from './directory-transfer-dialog'; import { DirectoryTree } from './directory-tree'; import { ResizablePanelGroup, @@ -31,6 +33,7 @@ import { import { Folder, FolderUp, + FolderDown, File, Upload, Download, @@ -63,7 +66,8 @@ import { Pencil, Loader2, CornerLeftUp, - SearchX + SearchX, + LocateFixed, } from 'lucide-react'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, ContextMenuSeparator } from './ui/context-menu'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog"; @@ -85,6 +89,7 @@ interface IntegratedFileBrowserProps { connectionId: string; host?: string; isConnected: boolean; + terminalWorkingDirectory?: { path: string; sequence: number }; onClose: () => void; /** Called when user wants to open a file in the Log Monitor */ onOpenInLogMonitor?: (filePath: string) => void; @@ -110,8 +115,9 @@ const treeStateCache = new Map(); +const FOLLOW_TERMINAL_DIRECTORY_KEY = 'rshell-follow-terminal-directory'; -export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, onClose: _onClose, onOpenInLogMonitor, onOpenInEditor }: IntegratedFileBrowserProps) { +export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, terminalWorkingDirectory, onClose: _onClose, onOpenInLogMonitor, onOpenInEditor }: IntegratedFileBrowserProps) { const { t } = useTranslation(); const [currentPath, setCurrentPath] = useState('/home'); const [files, setFiles] = useState([]); @@ -122,7 +128,11 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, const [searchTerm, setSearchTerm] = useState(''); const [isLoading, setIsLoading] = useState(false); const [showLoadingOverlay, setShowLoadingOverlay] = useState(false); + const [followTerminalDirectory, setFollowTerminalDirectory] = useState( + () => localStorage.getItem(FOLLOW_TERMINAL_DIRECTORY_KEY) !== 'false', + ); const loadingTimerRef = useRef | null>(null); + const lastFailedFollowPathRef = useRef(null); // Tracks which connectionId the current path/files state belongs to. // Updated synchronously (via ref) in the restore effect so the save effect // never writes stale data from the previous connection under the new id. @@ -135,6 +145,9 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, // connection-change loads (leaving them to the safety-net) and only handle // path / isConnected changes within the same connection. const prevConnectionIdRef = useRef(undefined); + // A successful follow load already fetched its target before committing the + // breadcrumb path. Skip the normal path-change load once to avoid a duplicate request. + const followedPathLoadRef = useRef(null); // Tracks the path that is authoritative for the current connectionId. // Updated synchronously in the restore effect (before setState), so the load // effect always uses the correct path even before React re-renders with the @@ -145,6 +158,11 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, const [renamingFile, setRenamingFile] = useState(null); const [newFileName, setNewFileName] = useState(''); const [deletingFile, setDeletingFile] = useState(null); + const [directoryTransfer, setDirectoryTransfer] = useState<{ + sourcePath: string; + destinationRoot: string; + destinationDirectoryName: string; + } | null>(null); // Column widths state const [columnWidths, setColumnWidths] = useState({ @@ -243,6 +261,10 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, if (!isConnected || !connectionId) return; // Skip if connectionId just changed — the safety-net effect handles that. if (prevConnectionIdRef.current !== connectionId) return; + if (followedPathLoadRef.current === committedPathRef.current) { + followedPathLoadRef.current = null; + return; + } void loadFiles(committedPathRef.current); // eslint-disable-next-line react-hooks/exhaustive-deps -- loadFiles is a stable inline fn; adding it would cause infinite re-renders }, [currentPath, isConnected, connectionId]); @@ -261,6 +283,20 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, // eslint-disable-next-line react-hooks/exhaustive-deps -- loadFiles is a stable inline fn }, [connectionId, isConnected]); + useEffect(() => { + if ( + !followTerminalDirectory + || !isConnected + || !terminalWorkingDirectory + || terminalWorkingDirectory.path === committedPathRef.current + ) { + return; + } + + void loadFiles(terminalWorkingDirectory.path, true); + // eslint-disable-next-line react-hooks/exhaustive-deps -- sequence intentionally re-runs follow after every completed prompt + }, [connectionId, isConnected, followTerminalDirectory, terminalWorkingDirectory?.path, terminalWorkingDirectory?.sequence]); + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -513,7 +549,7 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, [connectionId], ); - async function loadFiles(pathOverride?: string) { + async function loadFiles(pathOverride?: string, preservePathOnError = false) { if (!connectionId || !isConnected) { // Don't clear files on disconnect — preserve the cached file list so // the user still sees their directory contents when reconnecting or @@ -592,12 +628,14 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, // so we update it here together with files in a single batched // setState — breadcrumb and file list stay consistent. if (currentPath !== targetPath) { + if (preservePathOnError) followedPathLoadRef.current = targetPath; setCurrentPath(targetPath); setNavHistory([targetPath]); setNavIndex(0); setSelectedFiles(new Set()); } lastLoadedConnectionIdRef.current = connectionId; + lastFailedFollowPathRef.current = null; } else { // Empty or whitespace-only output — directory is genuinely empty // or the SSH command returned nothing. @@ -614,17 +652,30 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, }] : []; setFiles(emptyFiles); if (currentPath !== targetPath) { + if (preservePathOnError) followedPathLoadRef.current = targetPath; setCurrentPath(targetPath); setNavHistory([targetPath]); setNavIndex(0); setSelectedFiles(new Set()); } lastLoadedConnectionIdRef.current = connectionId; + lastFailedFollowPathRef.current = null; } } catch (error) { // CancelledError means a newer load superseded this one — discard silently. if (error instanceof CancelledError || gen !== loadGenRef.current) return; + if (preservePathOnError) { + const failedPath = `${connectionId}:${targetPath}`; + if (lastFailedFollowPathRef.current !== failedPath) { + lastFailedFollowPathRef.current = failedPath; + toast.warning(t('fileBrowser.toast.followDirectoryFailed', { path: targetPath }), { + description: error instanceof Error ? error.message : t('fileBrowser.toast.loadFailedDesc'), + }); + } + return; + } + // If the target path doesn't exist on this server (ls exit code 2), // fall back to /home. This commonly happens when switching to a // connection whose cached path from a previous session doesn't exist @@ -953,6 +1004,21 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, } }; + const handleDownloadDirectory = async (directory: FileItem) => { + try { + const destDir = await tauriOpen({ directory: true }); + if (!destDir || Array.isArray(destDir)) return; + + setDirectoryTransfer({ + sourcePath: directory.path, + destinationRoot: destDir, + destinationDirectoryName: directory.name, + }); + } catch (error) { + console.error('Download directory dialog error:', error); + } + }; + const handleCreateFolder = async () => { const folderName = prompt(t('fileBrowser.toast.enterFolderName')); if (folderName) { @@ -1432,6 +1498,18 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected, )}
+ { + setFollowTerminalDirectory(pressed); + localStorage.setItem(FOLLOW_TERMINAL_DIRECTORY_KEY, String(pressed)); + }} + className="h-6 w-6 shrink-0 rounded-md" + title={t('fileBrowser.toolbar.followTerminalDirectory')} + > + + + {/* Refresh */}