diff --git a/src-tauri/src/cmd/connection.rs b/src-tauri/src/cmd/connection.rs index db1a5380..3a8ec72e 100644 --- a/src-tauri/src/cmd/connection.rs +++ b/src-tauri/src/cmd/connection.rs @@ -297,6 +297,7 @@ mod tests { username: "root".to_string(), backspace_mode: "del".to_string(), x11_forwarding: false, + encoding: String::new(), }, group_id: None, description: None, @@ -335,6 +336,7 @@ mod tests { send_naws: true, send_sga: true, auto_login: Default::default(), + encoding: String::new(), }, group_id: None, description: None, @@ -364,6 +366,7 @@ mod tests { shell_args: String::new(), working_dir: None, ai_execution_profile: AiExecutionProfile::Auto, + encoding: String::new(), }, group_id: None, description: None, diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index 5f39d563..0ce17cd8 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -76,7 +76,10 @@ pub async fn create_temporary_ssh_session( config: ssh::SshConfig, create_request_id: Option, ) -> AppResult { - let ssh_config = normalize_temporary_ssh_config(config); + let encoding = crate::config::load_app_settings(&app) + .map(|settings| settings.interaction.default_encoding) + .unwrap_or_else(|_| "UTF-8".to_string()); + let ssh_config = normalize_temporary_ssh_config(config, &encoding); let pending_creation = state.begin_session_creation(create_request_id).await; let (guard, cancel_rx) = match pending_creation { Some((guard, cancel_rx)) => (Some(guard), Some(cancel_rx)), @@ -104,7 +107,7 @@ pub async fn create_temporary_ssh_session( Ok(session_id) } -fn normalize_temporary_ssh_config(mut config: ssh::SshConfig) -> ssh::SshConfig { +fn normalize_temporary_ssh_config(mut config: ssh::SshConfig, encoding: &str) -> ssh::SshConfig { config.connection_id = None; config.owner_window_label = None; config.backspace_mode = if config.backspace_mode.trim().is_empty() { @@ -118,6 +121,10 @@ fn normalize_temporary_ssh_config(mut config: ssh::SshConfig) -> ssh::SshConfig config.proxy_jump = None; config.post_login = None; config.ssh_algorithms = None; + // Inherit encoding from global settings if not explicitly set. + if config.encoding.trim().is_empty() || config.encoding == "UTF-8" { + config.encoding = encoding.to_string(); + } config } @@ -165,6 +172,7 @@ pub async fn create_local_session( }; let config = if let Some(ref cid) = connection_id { let conn = config::load_connection_by_id(&app, cid)?; + let encoding = config::resolve_connection_encoding(&app, &conn); match conn.config { config::ConnectionType::LocalTerminal { shell_path, @@ -176,6 +184,7 @@ pub async fn create_local_session( shell_args, working_dir, name: conn.name, + encoding, }), _ => None, } @@ -226,6 +235,7 @@ pub async fn create_telnet_session( let cfg = if let Some(ref cid) = connection_id { let conn = config::load_connection_by_id(&app, cid)?; let telnet_password = resolve_telnet_connection_password(&app, conn.auth.as_ref())?; + let encoding = config::resolve_connection_encoding(&app, &conn); match conn.config { config::ConnectionType::Telnet { host: ref ch, @@ -256,6 +266,7 @@ pub async fn create_telnet_session( send_naws, send_sga, auto_login: core::TelnetAutoLoginConfig::from(auto_login), + encoding, }, _ => { return Err(AppError::Config( @@ -352,6 +363,7 @@ pub async fn create_serial_session( }; let cfg = if let Some(ref cid) = connection_id { let conn = config::load_connection_by_id(&app, cid)?; + let encoding = config::resolve_connection_encoding(&app, &conn); match conn.config { config::ConnectionType::Serial { port_name, @@ -369,6 +381,7 @@ pub async fn create_serial_session( stop_bits, name: conn.name, backspace_mode, + encoding, }, _ => { return Err(AppError::Config( @@ -377,6 +390,9 @@ pub async fn create_serial_session( } } } else { + let encoding = crate::config::load_app_settings(&app) + .map(|s| s.interaction.default_encoding) + .unwrap_or_else(|_| "UTF-8".to_string()); core::SerialConfig { port_name: port_name .ok_or_else(|| AppError::Config("port_name is required".to_string()))?, @@ -386,6 +402,7 @@ pub async fn create_serial_session( stop_bits: stop_bits.unwrap_or_else(|| "1".to_string()), name: name.unwrap_or_else(|| "Serial".to_string()), backspace_mode: "ctrl_h".to_string(), + encoding, } }; let marked_connection_id = connection_id.clone(); @@ -578,7 +595,7 @@ mod tests { })) .expect("temporary ssh config"); - let normalized = normalize_temporary_ssh_config(config); + let normalized = normalize_temporary_ssh_config(config, "UTF-8"); assert!(normalized.connection_id.is_none()); assert!(normalized.owner_window_label.is_none()); diff --git a/src-tauri/src/config/connection.rs b/src-tauri/src/config/connection.rs index 5c81fc62..24f0dc44 100644 --- a/src-tauri/src/config/connection.rs +++ b/src-tauri/src/config/connection.rs @@ -89,6 +89,8 @@ pub enum ConnectionType { backspace_mode: String, #[serde(default, skip_serializing_if = "is_false")] x11_forwarding: bool, + #[serde(default)] + encoding: String, }, LocalTerminal { #[serde(default)] @@ -99,6 +101,8 @@ pub enum ConnectionType { working_dir: Option, #[serde(default, skip_serializing_if = "is_ai_execution_profile_auto")] ai_execution_profile: AiExecutionProfile, + #[serde(default)] + encoding: String, }, Telnet { host: String, @@ -129,6 +133,8 @@ pub enum ConnectionType { send_sga: bool, #[serde(default, skip_serializing_if = "is_default_telnet_auto_login")] auto_login: TelnetAutoLoginConfig, + #[serde(default)] + encoding: String, }, Serial { port_name: String, @@ -144,6 +150,8 @@ pub enum ConnectionType { ai_execution_profile: AiExecutionProfile, #[serde(default = "default_backspace_mode_serial")] backspace_mode: String, + #[serde(default)] + encoding: String, }, } @@ -466,6 +474,23 @@ fn migrate_connection_proxies_to_standalone( Ok(()) } +/// Returns the effective encoding for a connection: per-connection override if set, +/// otherwise the global default from `interaction.default_encoding`. +pub fn resolve_connection_encoding(app: &AppHandle, conn: &SavedConnection) -> String { + let per_conn = match &conn.config { + ConnectionType::Ssh { encoding, .. } + | ConnectionType::LocalTerminal { encoding, .. } + | ConnectionType::Telnet { encoding, .. } + | ConnectionType::Serial { encoding, .. } => encoding.as_str(), + }; + if !per_conn.is_empty() { + return per_conn.to_string(); + } + crate::config::load_app_settings(app) + .map(|s| s.interaction.default_encoding) + .unwrap_or_else(|_| "UTF-8".to_string()) +} + /// Loads a single connection by ID. /// /// Returns `AppError::SessionNotFound` if no connection with that ID exists. diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index a1b16962..fed12ed1 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -28,7 +28,7 @@ pub use connection::{ AiExecutionProfile, AppConfig, ConnectionAuth, ConnectionNetwork, ConnectionType, Group, SavedConnection, SessionsConfig, SftpCwdFollowMode, SftpSettings, SshAlgorithmMode, SshAlgorithmPreferences, TelnetAutoLoginConfig, load_config, load_connection_by_id, - load_sessions, save_config, save_sessions, + load_sessions, resolve_connection_encoding, save_config, save_sessions, }; #[allow(unused_imports)] pub use credential::{ diff --git a/src-tauri/src/core/importer/electerm.rs b/src-tauri/src/core/importer/electerm.rs index c14e4e1b..e5b891d5 100644 --- a/src-tauri/src/core/importer/electerm.rs +++ b/src-tauri/src/core/importer/electerm.rs @@ -144,6 +144,7 @@ fn prepare_electerm_bookmark( username, backspace_mode: "del".to_string(), x11_forwarding: false, + encoding: String::new(), }, group_path, description: None, diff --git a/src-tauri/src/core/importer/merge.rs b/src-tauri/src/core/importer/merge.rs index 4ca524dc..453a534f 100644 --- a/src-tauri/src/core/importer/merge.rs +++ b/src-tauri/src/core/importer/merge.rs @@ -92,6 +92,7 @@ fn import_legacy_sessions( username: sess.username, backspace_mode: "del".to_string(), x11_forwarding: false, + encoding: String::new(), }, group_id, description: sess.description, diff --git a/src-tauri/src/core/importer/nyaterm_json.rs b/src-tauri/src/core/importer/nyaterm_json.rs index 7e3cbb69..a204fb02 100644 --- a/src-tauri/src/core/importer/nyaterm_json.rs +++ b/src-tauri/src/core/importer/nyaterm_json.rs @@ -119,6 +119,7 @@ fn prepare_nyaterm_json_session( username: required_string(username, "username", &context)?, backspace_mode: "del".to_string(), x11_forwarding: false, + encoding: String::new(), }, group_path: normalize_optional_group_path(group_path, &context)?, description: normalize_optional_string(description), @@ -150,6 +151,7 @@ fn prepare_nyaterm_json_session( shell_args, working_dir: normalize_optional_string(working_dir), ai_execution_profile: AiExecutionProfile::Auto, + encoding: String::new(), }, group_path: normalize_optional_group_path(group_path, &context)?, description: normalize_optional_string(description), @@ -187,6 +189,7 @@ fn prepare_nyaterm_json_session( send_naws: true, send_sga: true, auto_login: Default::default(), + encoding: String::new(), }, group_path: normalize_optional_group_path(group_path, &context)?, description: normalize_optional_string(description), @@ -220,6 +223,7 @@ fn prepare_nyaterm_json_session( stop_bits, ai_execution_profile: AiExecutionProfile::Auto, backspace_mode, + encoding: String::new(), }, group_path: normalize_optional_group_path(group_path, &context)?, description: normalize_optional_string(description), diff --git a/src-tauri/src/core/importer/termius.rs b/src-tauri/src/core/importer/termius.rs index f495415e..d53b9036 100644 --- a/src-tauri/src/core/importer/termius.rs +++ b/src-tauri/src/core/importer/termius.rs @@ -1071,6 +1071,7 @@ fn prepare_termius_connections( username, backspace_mode: "del".to_string(), x11_forwarding: false, + encoding: String::new(), }, group_path, description: Some("Imported from Termius".to_string()), diff --git a/src-tauri/src/core/sftp/mod.rs b/src-tauri/src/core/sftp/mod.rs index 55415188..d219195f 100644 --- a/src-tauri/src/core/sftp/mod.rs +++ b/src-tauri/src/core/sftp/mod.rs @@ -54,6 +54,7 @@ pub(crate) struct AutoRemoteFs { inner: RwLock>>, ssh_handle: Arc, cache_key: String, + encoding: String, } impl AutoRemoteFs { @@ -62,11 +63,13 @@ impl AutoRemoteFs { host: &str, port: u16, username: &str, + encoding: &str, ) -> Self { Self { inner: RwLock::new(None), ssh_handle, cache_key: cache_key(host, port, username), + encoding: encoding.to_string(), } } @@ -108,7 +111,7 @@ impl AutoRemoteFs { match SftpBackend::probe(&self.ssh_handle).await { Ok(()) => { save_cached_backend(&self.cache_key, "sftp", false, None); - return Ok(Box::new(SftpBackend::new(self.ssh_handle.clone()))); + return Ok(Box::new(SftpBackend::new(self.ssh_handle.clone(), &self.encoding))); } Err(e) => { let reason = e.to_string(); @@ -152,7 +155,7 @@ impl AutoRemoteFs { .await .ok() .map(|()| -> Box { - Box::new(SftpBackend::new(self.ssh_handle.clone())) + Box::new(SftpBackend::new(self.ssh_handle.clone(), &self.encoding)) }) } "scp_enhanced" => ScpEnhancedBackend::probe(&self.ssh_handle).await.ok().map( @@ -184,7 +187,7 @@ impl AutoRemoteFs { async fn get_ssh_info( manager: &SessionManager, session_id: &str, -) -> AppResult<(Arc, String, u16, String)> { +) -> AppResult<(Arc, String, u16, String, String)> { let sessions = manager.sessions.lock().await; let session = sessions .get(session_id) @@ -198,17 +201,17 @@ async fn get_ssh_info( .downcast::() .map_err(|_| AppError::Config("Failed to get SSH handle".to_string()))?; - let (host, port, username) = if let Some(ref cfg_any) = session.ssh_config { + let (host, port, username, encoding) = if let Some(ref cfg_any) = session.ssh_config { if let Some(cfg) = cfg_any.downcast_ref::() { - (cfg.host.clone(), cfg.port, cfg.username.clone()) + (cfg.host.clone(), cfg.port, cfg.username.clone(), cfg.encoding.clone()) } else { - ("unknown".to_string(), 22, "unknown".to_string()) + ("unknown".to_string(), 22, "unknown".to_string(), "UTF-8".to_string()) } } else { - ("unknown".to_string(), 22, "unknown".to_string()) + ("unknown".to_string(), 22, "unknown".to_string(), "UTF-8".to_string()) }; - Ok((ssh_handle, host, port, username)) + Ok((ssh_handle, host, port, username, encoding)) } async fn get_or_create_auto_fs( @@ -230,8 +233,8 @@ async fn get_or_create_auto_fs( } } - let (ssh_handle, host, port, username) = get_ssh_info(manager, session_id).await?; - let auto_fs = Arc::new(AutoRemoteFs::new(ssh_handle, &host, port, &username)); + let (ssh_handle, host, port, username, encoding) = get_ssh_info(manager, session_id).await?; + let auto_fs = Arc::new(AutoRemoteFs::new(ssh_handle, &host, port, &username, &encoding)); { let mut sessions = manager.sessions.lock().await; diff --git a/src-tauri/src/core/sftp/sftp_backend.rs b/src-tauri/src/core/sftp/sftp_backend.rs index b149ea97..5a90409a 100644 --- a/src-tauri/src/core/sftp/sftp_backend.rs +++ b/src-tauri/src/core/sftp/sftp_backend.rs @@ -8,6 +8,7 @@ use super::util::*; use crate::core::ssh::SshConnectionHandles; use crate::error::{AppError, AppResult}; use crate::observability::{StructuredLog, StructuredLogLevel, log_event}; +use encoding_rs; use russh::{ChannelMsg, ChannelOpenFailure}; use russh_sftp::client::{Config as SftpClientConfig, SftpSession, error::Error as SftpError}; use russh_sftp::protocol::{FileAttributes, FileType, StatusCode}; @@ -69,6 +70,7 @@ fn is_sftp_not_found(error: &SftpError) -> bool { ) } +#[allow(dead_code)] fn ignore_sftp_not_found(result: Result<(), SftpError>) -> AppResult<()> { match result { Ok(()) => Ok(()), @@ -86,6 +88,7 @@ fn is_retryable_sftp_channel_open_error(error: &russh::Error) -> bool { ) } +#[allow(dead_code)] fn sftp_remove_error(path: &str, kind: &str, error: SftpError) -> Option { if is_sftp_not_found(&error) { None @@ -293,6 +296,11 @@ fn log_transfer_performance( pub(crate) struct SftpBackend { ssh_handle: Arc, identity_cache: Arc>, + /// Cache mapping decoded paths to their raw byte representations. + /// Used to preserve original encoding for non-UTF-8 file names. + path_cache: Arc>>>, + /// Encoding for this connection (e.g., "UTF-8", "GBK") + encoding: String, } #[derive(Default)] @@ -310,10 +318,57 @@ struct ExecResult { } impl SftpBackend { - pub(crate) fn new(ssh_handle: Arc) -> Self { + pub(crate) fn new(ssh_handle: Arc, encoding: &str) -> Self { Self { ssh_handle, identity_cache: Arc::new(RwLock::new(RemoteIdentityCache::default())), + path_cache: Arc::new(RwLock::new(HashMap::new())), + encoding: encoding.to_string(), + } + } + + /// Get the encoding setting for this connection + pub(crate) fn encoding(&self) -> &str { + &self.encoding + } + + /// Convert UTF-8 path to raw bytes for SFTP operations. + /// Uses the connection's encoding setting. + fn encode_path_for_sftp(&self, path: &str) -> Vec { + if path.bytes().all(|b| b < 128) { + // ASCII-only path, use as-is + return path.as_bytes().to_vec(); + } + + // Path contains non-ASCII characters, encode using connection encoding + match self.encoding.to_uppercase().as_str() { + "GBK" | "GB2312" | "GB18030" => { + let (gbk_bytes, _, _) = encoding_rs::GBK.encode(path); + gbk_bytes.into_owned() + } + _ => { + // UTF-8 or unknown encoding, use UTF-8 bytes + path.as_bytes().to_vec() + } + } + } + + /// Decode raw bytes to string using the connection's encoding. + fn decode_path_from_sftp(&self, bytes: &[u8]) -> String { + match self.encoding.to_uppercase().as_str() { + "GBK" | "GB2312" | "GB18030" => { + let (decoded, _, had_errors) = encoding_rs::GBK.decode(bytes); + if had_errors { + // Fallback to UTF-8 lossy if GBK decoding fails + String::from_utf8_lossy(bytes).into_owned() + } else { + decoded.into_owned() + } + } + _ => { + // UTF-8 or unknown encoding + String::from_utf8_lossy(bytes).into_owned() + } } } @@ -637,6 +692,7 @@ fn join_remote_child(parent: &str, name: &str) -> String { } } +#[allow(dead_code)] fn is_safe_recursive_remove_target(path: &str) -> bool { let trimmed = path.trim(); if trimmed.is_empty() || matches!(trimmed, "/" | "." | "..") { @@ -979,6 +1035,7 @@ async fn download_known_size_to_local_file( max_pipeline_depth: usize, controller: &Arc, parent_controller: Option<&Arc>, + path_cache: &RwLock>>, mut on_bytes: F, mut on_progress_interval: G, ) -> AppResult @@ -1008,12 +1065,23 @@ where .min(max_pipeline_depth.max(1)) .min(num_chunks); + // Look up raw bytes path from cache for non-UTF-8 file names + let cache = path_cache.read().await; + let raw_path = cache.get(remote_path).cloned(); + drop(cache); + let mut handle_pool: Vec = Vec::with_capacity(concurrency); for _ in 0..concurrency { handle_pool.push( - sftp.open(remote_path) - .await - .map_err(|e| AppError::Channel(format!("Failed to open remote file: {}", e)))?, + if let Some(ref bytes) = raw_path { + sftp.open_bytes(bytes.clone()) + .await + .map_err(|e| AppError::Channel(format!("Failed to open remote file: {}", e)))? + } else { + sftp.open(remote_path) + .await + .map_err(|e| AppError::Channel(format!("Failed to open remote file: {}", e)))? + }, ); } @@ -1167,6 +1235,7 @@ async fn download_remote_file_inner_with_controller( pipeline_depth, &controller, parent_controller.as_ref(), + &backend.path_cache, |current, _delta| { controller.update_progress(current, total_size); }, @@ -1181,11 +1250,21 @@ async fn download_remote_file_inner_with_controller( ) .await?; } else { + // Look up raw bytes path from cache for non-UTF-8 file names + let cache = backend.path_cache.read().await; + let raw_path = cache.get(remote_path).cloned(); + drop(cache); + let mut last_progress = Instant::now(); - let mut remote_file = sftp - .open(remote_path) - .await - .map_err(|e| AppError::Channel(format!("Failed to open remote file: {}", e)))?; + let mut remote_file = if let Some(ref bytes) = raw_path { + sftp.open_bytes(bytes.clone()) + .await + .map_err(|e| AppError::Channel(format!("Failed to open remote file: {}", e)))? + } else { + sftp.open(remote_path) + .await + .map_err(|e| AppError::Channel(format!("Failed to open remote file: {}", e)))? + }; let seq_chunk = (chunk_size as usize).max(64 * 1024); let mut buf = vec![0u8; seq_chunk]; @@ -1323,10 +1402,28 @@ async fn upload_local_file_inner_with_controller( let mut local_file = tokio::fs::File::open(local_path) .await .map_err(|e| AppError::Channel(format!("Failed to open local file: {}", e)))?; - let mut remote_file = sftp - .create(remote_path) + let mut remote_file = if backend.encoding().to_uppercase() != "UTF-8" { + // Encode UTF-8 path to GBK bytes + let path_bytes = match backend.encoding().to_uppercase().as_str() { + "GBK" | "GB2312" | "GB18030" => { + let (gbk_bytes, _, _) = encoding_rs::GBK.encode(remote_path); + gbk_bytes.into_owned() + } + _ => remote_path.as_bytes().to_vec(), + }; + // Use open_with_flags_bytes with WRITE|CREATE|TRUNCATE flags + use russh_sftp::protocol::OpenFlags; + sftp.open_with_flags_bytes( + path_bytes, + OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE, + ) .await - .map_err(|e| AppError::Channel(format!("Failed to create remote file: {}", e)))?; + .map_err(|e| AppError::Channel(format!("Failed to create remote file: {}", e)))? + } else { + sftp.create(remote_path) + .await + .map_err(|e| AppError::Channel(format!("Failed to create remote file: {}", e)))? + }; if total_size > 0 { let mut buf = vec![0u8; chunk_size]; @@ -1449,22 +1546,67 @@ impl RemoteFs for SftpBackend { async fn list_dir(&self, path: &str) -> AppResult> { let sftp = self.open_sftp().await?; - let dir = sftp.read_dir(path).await?; + + // Convert UTF-8 path to raw bytes for GBK encoding + let path_bytes = self.encode_path_for_sftp(path); + let dir = sftp.read_dir_bytes(path_bytes).await?; let mut pending = Vec::new(); let mut uid_set = HashSet::new(); let mut gid_set = HashSet::new(); + let normalized_path = normalize_remote_dir_path(path); + for entry in dir { - let name = entry.file_name(); - if name == "." || name == ".." { + let name_from_entry = entry.file_name(); + if name_from_entry == "." || name_from_entry == ".." { continue; } + + // Get raw bytes for the file name to preserve original encoding + let name_bytes = entry.file_name_bytes().to_vec(); + + // Decode using the connection's encoding setting + let name = self.decode_path_from_sftp(&name_bytes); + + let full_path = join_remote_child(&normalized_path, &name); + + // Cache the raw bytes path for later use in file operations + let mut cache = self.path_cache.write().await; + cache.insert(full_path.clone(), { + // For non-UTF-8 encodings, encode the entire path in the target encoding + if self.encoding.to_uppercase() != "UTF-8" { + let mut path_bytes = match self.encoding.to_uppercase().as_str() { + "GBK" | "GB2312" | "GB18030" => { + let (gbk_bytes, _, _) = encoding_rs::GBK.encode(&normalized_path); + gbk_bytes.into_owned() + } + _ => normalized_path.as_bytes().to_vec(), + }; + if !path_bytes.ends_with(b"/") { + path_bytes.push(b'/'); + } + path_bytes.extend_from_slice(&name_bytes); + path_bytes + } else { + // UTF-8 encoding - just use as_bytes + let mut path_bytes = normalized_path.as_bytes().to_vec(); + if !path_bytes.ends_with(b"/") { + path_bytes.push(b'/'); + } + path_bytes.extend_from_slice(&name_bytes); + path_bytes + } + }); + + drop(cache); + let file_type = entry.file_type(); let is_symlink = file_type == FileType::Symlink; - let full_path = join_remote_child(normalize_remote_dir_path(path), &name); + + // Use raw bytes for metadata operation to handle non-UTF-8 paths let is_symlink_to_dir = is_symlink && sftp - .metadata(&full_path) + .metadata_bytes(name_bytes.clone()) .await .ok() .as_ref() @@ -1602,29 +1744,35 @@ impl RemoteFs for SftpBackend { async fn remove_file(&self, path: &str) -> AppResult<()> { let sftp = self.open_sftp().await?; - let meta = match sftp.symlink_metadata(path).await { - Ok(meta) => meta, - Err(error) if is_sftp_not_found(&error) => { - let _ = sftp.close().await; - return Ok(()); - } - Err(error) => { - let _ = sftp.close().await; - return Err(error.into()); - } - }; - if sftp_attrs_is_symlink(&meta) { - ignore_sftp_not_found(sftp.remove_file(path).await)?; - } else if sftp_attrs_is_dir(&meta) { - let _ = sftp.close().await; - self.remove_dir_fast(path).await?; - return Ok(()); - } else { - ignore_sftp_not_found(sftp.remove_file(path).await)?; + // For non-UTF-8 encodings, use raw bytes path from cache + if self.encoding.to_uppercase() != "UTF-8" { + let cache = self.path_cache.read().await; + if let Some(raw_bytes) = cache.get(path) { + let raw_bytes = raw_bytes.clone(); + drop(cache); + + // Try to remove using raw bytes directly + let result = sftp.remove_file_bytes(raw_bytes).await; + let _ = sftp.close().await; + + return match result { + Ok(()) => Ok(()), + Err(error) if is_sftp_not_found(&error) => Ok(()), + Err(error) => Err(error.into()), + }; + } } + + // UTF-8 encoding or path not in cache - use regular path + let result = sftp.remove_file(path).await; let _ = sftp.close().await; - Ok(()) + + match result { + Ok(()) => Ok(()), + Err(error) if is_sftp_not_found(&error) => Ok(()), + Err(error) => Err(error.into()), + } } async fn rename(&self, old_path: &str, new_path: &str) -> AppResult<()> { @@ -1636,13 +1784,43 @@ impl RemoteFs for SftpBackend { async fn create_file(&self, path: &str, mode: Option) -> AppResult<()> { let sftp = self.open_sftp().await?; - let file = sftp.create(path).await?; - drop(file); - if let Some(ref m) = mode { - apply_remote_mode_after_create(&sftp, path, m, "file").await?; + + // For non-UTF-8 encodings, encode the path in the target encoding + // and use open_bytes with WRITE flag to create the file + let result = if self.encoding.to_uppercase() != "UTF-8" { + // Encode UTF-8 path to GBK bytes + let path_bytes = match self.encoding.to_uppercase().as_str() { + "GBK" | "GB2312" | "GB18030" => { + let (gbk_bytes, _, _) = encoding_rs::GBK.encode(path); + gbk_bytes.into_owned() + } + _ => path.as_bytes().to_vec(), + }; + // Use open_bytes with WRITE|CREATE|TRUNCATE flags + use russh_sftp::protocol::OpenFlags; + sftp.open_with_flags_bytes( + path_bytes, + OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE, + ) + .await + } else { + sftp.create(path).await + }; + + match result { + Ok(file) => { + drop(file); + if let Some(ref m) = mode { + apply_remote_mode_after_create(&sftp, path, m, "file").await?; + } + let _ = sftp.close().await; + Ok(()) + } + Err(error) => { + let _ = sftp.close().await; + Err(error.into()) + } } - let _ = sftp.close().await; - Ok(()) } async fn create_symlink(&self, link_path: &str, target_path: &str) -> AppResult<()> { @@ -2187,11 +2365,13 @@ struct DirectoryTransferSummary { small_file_concurrency: usize, } +#[allow(dead_code)] struct RemoveInventory { files: Vec, dirs: Vec, } +#[allow(dead_code)] async fn collect_remove_inventory(sftp: &SftpSession, path: &str) -> AppResult { let path = normalize_remote_dir_path(path).to_string(); let dir = match sftp.read_dir(&path).await { @@ -2225,6 +2405,7 @@ async fn collect_remove_inventory(sftp: &SftpSession, path: &str) -> AppResult AppResult<()> { if is_safe_recursive_remove_target(path) { let command = format!("rm -rf -- {}", sh_quote(normalize_remote_dir_path(path))); @@ -2309,6 +2491,7 @@ impl SftpBackend { self.remove_dir_concurrent_sftp(path).await } + #[allow(dead_code)] async fn remove_dir_concurrent_sftp(&self, path: &str) -> AppResult<()> { let sftp = self.open_sftp().await?; let max_open_handles = sftp.max_open_handles(); @@ -2566,6 +2749,7 @@ impl SftpBackend { directory_controller, transfer_settings, concurrency, + self.path_cache.clone(), ) .await; pool.close_all().await; @@ -2635,6 +2819,7 @@ async fn run_download_directory_workers( directory_controller: Arc, transfer_settings: &crate::config::TransferSettings, concurrency: SftpDirectoryConcurrency, + path_cache: Arc>>>, ) -> AppResult { let worker_count = sftp_directory_file_concurrency(inventory.files.len(), concurrency); let total_files = inventory.total_files; @@ -2654,6 +2839,7 @@ async fn run_download_directory_workers( let completed_bytes = completed_bytes.clone(); let large_lane = large_lane.clone(); let transfer_settings = transfer_settings.clone(); + let path_cache = path_cache.clone(); join_set.spawn(async move { loop { wait_for_transfer_ready(&directory_controller).await?; @@ -2681,6 +2867,7 @@ async fn run_download_directory_workers( &completed_bytes, total_size, concurrency.small_file_concurrency, + &path_cache, ) .await?; let completed = completed_count.fetch_add(1, Ordering::SeqCst) + 1; @@ -2834,6 +3021,7 @@ async fn download_directory_file_with_session( completed_bytes: &Arc, total_size: u64, max_pipeline_depth: usize, + path_cache: &RwLock>>, ) -> AppResult { use tokio::io::AsyncWriteExt; @@ -2872,6 +3060,7 @@ async fn download_directory_file_with_session( max_pipeline_depth, directory_controller, None, + path_cache, |_current, delta| { add_directory_transferred_bytes( directory_controller, diff --git a/src-tauri/src/core/ssh/auth.rs b/src-tauri/src/core/ssh/auth.rs index 16f393d5..a0bc81d6 100644 --- a/src-tauri/src/core/ssh/auth.rs +++ b/src-tauri/src/core/ssh/auth.rs @@ -366,6 +366,8 @@ fn resolve_saved_ssh_config( .map(|settings| settings.terminal.x11_display) .unwrap_or_default(); + let encoding = crate::config::resolve_connection_encoding(app, conn); + Ok(SshConfig { connection_id, owner_window_label: None, @@ -382,6 +384,7 @@ fn resolve_saved_ssh_config( post_login, ssh_algorithms: conn.ssh_algorithms.clone(), sftp: conn.sftp.clone(), + encoding, }) } diff --git a/src-tauri/src/core/ssh/client.rs b/src-tauri/src/core/ssh/client.rs index df248a4c..b4aed473 100644 --- a/src-tauri/src/core/ssh/client.rs +++ b/src-tauri/src/core/ssh/client.rs @@ -42,6 +42,13 @@ pub struct SshConfig { pub ssh_algorithms: Option, #[serde(default)] pub sftp: SftpSettings, + /// Character encoding for terminal I/O (e.g. "UTF-8", "GBK"). + #[serde(default = "default_encoding")] + pub encoding: String, +} + +fn default_encoding() -> String { + "UTF-8".to_string() } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] diff --git a/src-tauri/src/core/ssh/io.rs b/src-tauri/src/core/ssh/io.rs index e6491ec9..a4b579b0 100644 --- a/src-tauri/src/core/ssh/io.rs +++ b/src-tauri/src/core/ssh/io.rs @@ -3,6 +3,7 @@ use crate::config::SftpCwdFollowMode; use crate::core::capture::OutputCaptureProcessor; use crate::core::input::remap_del_to_bs; use crate::core::ssh::osc::{self, OscStripper, ShellKind}; +use crate::core::terminal_session::decode_terminal_output; use crate::core::zmodem::{ ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemDownloadOoDrain, ZmodemEvent, ZmodemTransfer, ZmodemUploadDrain, start_zmodem_transfer, @@ -553,6 +554,7 @@ pub(super) async fn ssh_io_loop( startup_command: Option, backspace_mode: String, initial_notice: Option, + encoding: String, ) { let backspace_as_bs = backspace_mode == "ctrl_h"; let output_event = format!("terminal-output-{}", session_id); @@ -633,7 +635,51 @@ pub(super) async fn ssh_io_loop( if let Some(ref recorder) = recording_mgr { recorder.write_input(&session_id, &data); } - let _ = channel.data(&data[..]).await; + // Convert UTF-8 input to target encoding if needed + let send_data = if encoding.to_uppercase() == "GBK" + || encoding.to_uppercase() == "GB2312" + || encoding.to_uppercase() == "GB18030" + { + // Convert UTF-8 bytes to GBK bytes + // For GBK encoding, always remap DEL (0x7F) to BS (0x08) + // because most GBK servers expect BS as the erase character + let mut result = Vec::with_capacity(data.len()); + let mut utf8_buffer = Vec::new(); + + for &byte in &data { + let effective_byte = if byte == 0x7F { 0x08 } else { byte }; + + if effective_byte < 0x20 { + // ASCII control character - flush buffered UTF-8 first + if !utf8_buffer.is_empty() { + let text = String::from_utf8_lossy(&utf8_buffer); + let (gbk_bytes, _, _) = encoding_rs::GBK.encode(&text); + result.extend_from_slice(&gbk_bytes); + utf8_buffer.clear(); + } + result.push(effective_byte); + } else { + utf8_buffer.push(byte); + } + } + + // Flush remaining buffered UTF-8 + if !utf8_buffer.is_empty() { + let text = String::from_utf8_lossy(&utf8_buffer); + let (gbk_bytes, _, _) = encoding_rs::GBK.encode(&text); + result.extend_from_slice(&gbk_bytes); + } + + result + } else { + // For UTF-8 mode, use the configured backspace mode + let mut data = data; + if backspace_as_bs { + remap_del_to_bs(&mut data); + } + data + }; + let _ = channel.data(&send_data[..]).await; } Some(SessionCommand::Resize { cols, rows }) => { let _ = channel.window_change(cols, rows, 0, 0).await; @@ -732,7 +778,7 @@ pub(super) async fn ssh_io_loop( ZmodemDetectResult::Detected { direction, passthrough, initial_bytes } => { // Forward any pre-header bytes to the terminal. if !passthrough.is_empty() { - let pre = String::from_utf8_lossy(&passthrough).to_string(); + let pre = decode_terminal_output(&passthrough, &encoding); if !pre.is_empty() { output.push_owned(pre); } @@ -764,7 +810,7 @@ pub(super) async fn ssh_io_loop( continue; } ZmodemDetectResult::NoMatch { passthrough } => { - let text = String::from_utf8_lossy(&passthrough).to_string(); + let text = decode_terminal_output(&passthrough, &encoding); let mut result = stripper.push(&text); if capture_processor.has_active() { @@ -797,7 +843,7 @@ pub(super) async fn ssh_io_loop( } } Some(ChannelMsg::ExtendedData { ref data, .. }) => { - let text = String::from_utf8_lossy(data).to_string(); + let text = decode_terminal_output(data, &encoding); if let Some(ref recorder) = recording_mgr { recorder.write_output(&session_id, &text); } diff --git a/src-tauri/src/core/ssh/session.rs b/src-tauri/src/core/ssh/session.rs index 65fa6e30..7ff14e8d 100644 --- a/src-tauri/src/core/ssh/session.rs +++ b/src-tauri/src/core/ssh/session.rs @@ -278,6 +278,7 @@ async fn create_ssh_session_inner( let post_login = config.post_login.clone(); let startup_command = startup_command.clone(); let backspace_mode = config.backspace_mode.clone(); + let encoding = config.encoding.clone(); tokio::spawn(async move { ssh_io_loop( app, @@ -296,6 +297,7 @@ async fn create_ssh_session_inner( startup_command, backspace_mode, initial_notice, + encoding, ) .await; }); @@ -418,6 +420,7 @@ pub async fn create_multiplexed_ssh_session( let post_login = config.post_login.clone(); let startup_command = startup_command.clone(); let backspace_mode = config.backspace_mode.clone(); + let encoding = config.encoding.clone(); tokio::spawn(async move { ssh_io_loop( app, @@ -436,6 +439,7 @@ pub async fn create_multiplexed_ssh_session( startup_command, backspace_mode, initial_notice, + encoding, ) .await; }); diff --git a/src-tauri/src/core/terminal_session/local/config.rs b/src-tauri/src/core/terminal_session/local/config.rs index 21e4d359..243032aa 100644 --- a/src-tauri/src/core/terminal_session/local/config.rs +++ b/src-tauri/src/core/terminal_session/local/config.rs @@ -4,6 +4,7 @@ pub struct LocalSessionConfig { pub shell_args: String, pub working_dir: Option, pub name: String, + pub encoding: String, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src-tauri/src/core/terminal_session/local/session.rs b/src-tauri/src/core/terminal_session/local/session.rs index eb9be3a5..ce62482c 100644 --- a/src-tauri/src/core/terminal_session/local/session.rs +++ b/src-tauri/src/core/terminal_session/local/session.rs @@ -51,6 +51,14 @@ pub async fn create_local_session( let sid = session_id.clone(); let mgr = manager.clone(); let rt_handle = tokio::runtime::Handle::current(); + let encoding = config + .as_ref() + .map(|c| c.encoding.clone()) + .unwrap_or_else(|| { + crate::config::load_app_settings(&app) + .map(|settings| settings.interaction.default_encoding) + .unwrap_or_else(|_| "UTF-8".to_string()) + }); std::thread::spawn(move || { pty_session_thread( @@ -64,6 +72,7 @@ pub async fn create_local_session( config, startup_script.script, ready_marker, + encoding, ); }); @@ -81,6 +90,7 @@ fn pty_session_thread( config: Option, startup_script: Option, ready_marker: String, + encoding: String, ) { let pty_system = native_pty_system(); let pair = match pty_system.openpty(PtySize { @@ -229,6 +239,7 @@ fn pty_session_thread( let output_reader = output.clone(); let manager_reader = manager.clone(); let suppress_startup_output = startup_script.is_some(); + let encoding_for_reader = encoding.clone(); let (reader_done_tx, reader_done_rx) = std_mpsc::channel::<()>(); std::thread::spawn(move || { let mut raw_buf = [0u8; 4096]; @@ -280,7 +291,7 @@ fn pty_session_thread( initial_bytes, } => { if !passthrough.is_empty() { - let pre = String::from_utf8_lossy(&passthrough).to_string(); + let pre = super::decode_terminal_output(&passthrough, &encoding_for_reader); if !pre.is_empty() { output_reader.push_owned(pre); } @@ -325,7 +336,7 @@ fn pty_session_thread( raw.to_vec() }; - let text = String::from_utf8_lossy(&process_raw).to_string(); + let text = super::decode_terminal_output(&process_raw, &encoding_for_reader); let mut result = stripper.push(&text); for path in &result.cwd_paths { diff --git a/src-tauri/src/core/terminal_session/mod.rs b/src-tauri/src/core/terminal_session/mod.rs index 3b3e1557..f6bbcebe 100644 --- a/src-tauri/src/core/terminal_session/mod.rs +++ b/src-tauri/src/core/terminal_session/mod.rs @@ -3,3 +3,15 @@ pub(crate) mod local; pub(crate) mod serial; pub(crate) mod telnet; + +/// Decodes raw bytes to a UTF-8 string using the specified encoding. +/// Falls back to UTF-8 lossy conversion if encoding is not recognized. +pub(crate) fn decode_terminal_output(data: &[u8], encoding: &str) -> String { + match encoding.to_uppercase().as_str() { + "GBK" | "GB2312" | "GB18030" => { + let (decoded, _, _) = encoding_rs::GBK.decode(data); + decoded.into_owned() + } + _ => String::from_utf8_lossy(data).into_owned(), + } +} diff --git a/src-tauri/src/core/terminal_session/serial/config.rs b/src-tauri/src/core/terminal_session/serial/config.rs index c393ebe4..c7cdbd48 100644 --- a/src-tauri/src/core/terminal_session/serial/config.rs +++ b/src-tauri/src/core/terminal_session/serial/config.rs @@ -6,4 +6,5 @@ pub struct SerialConfig { pub stop_bits: String, pub name: String, pub backspace_mode: String, + pub encoding: String, } diff --git a/src-tauri/src/core/terminal_session/serial/manager.rs b/src-tauri/src/core/terminal_session/serial/manager.rs index 005bf5b0..c76e1d73 100644 --- a/src-tauri/src/core/terminal_session/serial/manager.rs +++ b/src-tauri/src/core/terminal_session/serial/manager.rs @@ -72,6 +72,13 @@ pub async fn create_serial_session( let sid = session_id.clone(); let mgr = manager.clone(); let rt_handle = tokio::runtime::Handle::current(); + let encoding = if !config.encoding.is_empty() { + config.encoding.clone() + } else { + crate::config::load_app_settings(&app) + .map(|settings| settings.interaction.default_encoding) + .unwrap_or_else(|_| "UTF-8".to_string()) + }; std::thread::spawn(move || { serial_session_thread( @@ -84,6 +91,7 @@ pub async fn create_serial_session( rt_handle, config, connection_id, + encoding, port, reader_port, ); diff --git a/src-tauri/src/core/terminal_session/serial/session.rs b/src-tauri/src/core/terminal_session/serial/session.rs index d3c985d3..111d40a8 100644 --- a/src-tauri/src/core/terminal_session/serial/session.rs +++ b/src-tauri/src/core/terminal_session/serial/session.rs @@ -8,6 +8,7 @@ fn serial_session_thread( rt_handle: tokio::runtime::Handle, config: SerialConfig, connection_id: Option, + encoding: String, port: Box, mut reader_port: Box, ) { @@ -39,6 +40,7 @@ fn serial_session_thread( let port_writer_reader = port_writer.clone(); let output_reader = output.clone(); let recording_mgr_reader = recording_mgr.clone(); + let encoding_reader = encoding.clone(); let reader_running = Arc::new(std::sync::atomic::AtomicBool::new(true)); let reader_flag = reader_running.clone(); @@ -96,7 +98,7 @@ fn serial_session_thread( initial_bytes, } => { if !passthrough.is_empty() { - let pre = String::from_utf8_lossy(&passthrough).to_string(); + let pre = super::decode_terminal_output(&passthrough, &encoding_reader); if !pre.is_empty() { if let Some(ref recorder) = recording_mgr_reader { recorder.write_output(&sid_reader, &pre); @@ -138,7 +140,7 @@ fn serial_session_thread( } }; - let mut text = String::from_utf8_lossy(&process_raw).to_string(); + let mut text = super::decode_terminal_output(&process_raw, &encoding_reader); if let Ok(mut proc) = capture_for_reader.lock() { if proc.has_active() { text = proc.process(&text); diff --git a/src-tauri/src/core/terminal_session/telnet/manager.rs b/src-tauri/src/core/terminal_session/telnet/manager.rs index 7dcd8673..bedf38cd 100644 --- a/src-tauri/src/core/terminal_session/telnet/manager.rs +++ b/src-tauri/src/core/terminal_session/telnet/manager.rs @@ -52,6 +52,13 @@ pub async fn create_telnet_session( let sid = session_id.clone(); let mgr = manager.clone(); + let encoding = if !config.encoding.is_empty() { + config.encoding.clone() + } else { + crate::config::load_app_settings(&app) + .map(|settings| settings.interaction.default_encoding) + .unwrap_or_else(|_| "UTF-8".to_string()) + }; tokio::spawn(async move { telnet_session_task( @@ -62,6 +69,7 @@ pub async fn create_telnet_session( output_control_tx, config, connection_id, + encoding, startup_command, ) .await; diff --git a/src-tauri/src/core/terminal_session/telnet/session.rs b/src-tauri/src/core/terminal_session/telnet/session.rs index 7a6d7726..f3fec8b9 100644 --- a/src-tauri/src/core/terminal_session/telnet/session.rs +++ b/src-tauri/src/core/terminal_session/telnet/session.rs @@ -40,6 +40,7 @@ async fn telnet_session_task( output_control_tx: mpsc::UnboundedSender, config: TelnetSessionConfig, connection_id: Option, + encoding: String, startup_command: Option, ) { let backspace_as_bs = config.backspace_mode == "ctrl_h"; @@ -121,6 +122,7 @@ async fn telnet_session_task( let (reader_done_tx, mut reader_done_rx) = mpsc::unbounded_channel::<()>(); let reader_config = config.clone(); + let encoding_reader = encoding.clone(); let reader_handle = tokio::spawn(async move { let mut buf = [0u8; 4096]; let mut zmodem_detector = ZmodemDetector::new(); @@ -218,7 +220,7 @@ async fn telnet_session_task( initial_bytes, } => { if !passthrough.is_empty() { - let pre = String::from_utf8_lossy(&passthrough).to_string(); + let pre = super::decode_terminal_output(&passthrough, &encoding_reader); if !pre.is_empty() { if let Some(ref recorder) = recording_mgr_reader { recorder.write_output(&sid_reader, &pre); @@ -256,7 +258,7 @@ async fn telnet_session_task( } }; - let mut text = String::from_utf8_lossy(&process_visible).to_string(); + let mut text = super::decode_terminal_output(&process_visible, &encoding_reader); let mut proc = capture_for_reader.lock().await; if proc.has_active() { text = proc.process(&text); diff --git a/src-tauri/src/core/terminal_session/telnet/types.rs b/src-tauri/src/core/terminal_session/telnet/types.rs index 05383d38..1cc3310e 100644 --- a/src-tauri/src/core/terminal_session/telnet/types.rs +++ b/src-tauri/src/core/terminal_session/telnet/types.rs @@ -37,6 +37,7 @@ pub struct TelnetSessionConfig { pub send_naws: bool, pub send_sga: bool, pub auto_login: TelnetAutoLoginConfig, + pub encoding: String, } impl Default for TelnetSessionConfig { @@ -56,6 +57,7 @@ impl Default for TelnetSessionConfig { send_naws: true, send_sga: true, auto_login: TelnetAutoLoginConfig::default(), + encoding: String::new(), } } } diff --git a/src-tauri/src/storage/tests.rs b/src-tauri/src/storage/tests.rs index 69c80a86..95166226 100644 --- a/src-tauri/src/storage/tests.rs +++ b/src-tauri/src/storage/tests.rs @@ -39,6 +39,7 @@ fn sample_connection(id: &str, group_id: Option<&str>, sort_order: i32) -> Saved username: "root".to_string(), backspace_mode: "del".to_string(), x11_forwarding: false, + encoding: String::new(), }, group_id: group_id.map(str::to_string), description: None, diff --git a/src-tauri/vendor/russh-sftp/Cargo.toml b/src-tauri/vendor/russh-sftp/Cargo.toml index 5b02565f..16293f2d 100644 --- a/src-tauri/vendor/russh-sftp/Cargo.toml +++ b/src-tauri/vendor/russh-sftp/Cargo.toml @@ -29,6 +29,7 @@ chrono = "0.4.44" bytes = "1.11.1" log = "0.4.29" dashmap = "6.1.0" +encoding_rs = "0.8" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4.71" diff --git a/src-tauri/vendor/russh-sftp/src/client/fs/dir.rs b/src-tauri/vendor/russh-sftp/src/client/fs/dir.rs index f61f12d9..5aa7f311 100644 --- a/src-tauri/vendor/russh-sftp/src/client/fs/dir.rs +++ b/src-tauri/vendor/russh-sftp/src/client/fs/dir.rs @@ -8,6 +8,8 @@ use crate::protocol::FileType; pub struct DirEntry { parent: Arc, file: String, + /// Raw bytes of the file name, preserving original encoding. + file_bytes: Vec, metadata: Metadata, } @@ -17,6 +19,11 @@ impl DirEntry { self.file.to_owned() } + /// Returns the raw bytes of the file name. + pub fn file_name_bytes(&self) -> &[u8] { + &self.file_bytes + } + /// Returns the file type for the file that this entry points at. pub fn file_type(&self) -> FileType { self.metadata.file_type() @@ -44,12 +51,28 @@ impl DirEntry { format!("{}/{}", self.parent, self.file) } } + + /// Returns the full path as raw bytes. + pub fn path_bytes(&self) -> Vec { + if self.parent.is_empty() { + self.file_bytes.clone() + } else if self.parent.ends_with('/') { + let mut path = self.parent.as_bytes().to_vec(); + path.extend_from_slice(&self.file_bytes); + path + } else { + let mut path = self.parent.as_bytes().to_vec(); + path.push(b'/'); + path.extend_from_slice(&self.file_bytes); + path + } + } } /// Iterator over the entries in a remote directory. pub struct ReadDir { pub(crate) parent: Arc, - pub(crate) entries: VecDeque<(String, Metadata)>, + pub(crate) entries: VecDeque<(Vec, String, Metadata)>, } impl Iterator for ReadDir { @@ -58,11 +81,12 @@ impl Iterator for ReadDir { fn next(&mut self) -> Option { match self.entries.pop_front() { None => None, - Some(entry) if entry.0 == "." || entry.0 == ".." => self.next(), - Some(entry) => Some(DirEntry { + Some((_file_bytes, file, _metadata)) if file == "." || file == ".." => self.next(), + Some((file_bytes, file, metadata)) => Some(DirEntry { parent: self.parent.clone(), - file: entry.0, - metadata: entry.1, + file, + file_bytes, + metadata, }), } } diff --git a/src-tauri/vendor/russh-sftp/src/client/rawsession.rs b/src-tauri/vendor/russh-sftp/src/client/rawsession.rs index c8b419a2..90ecb1b4 100644 --- a/src-tauri/vendor/russh-sftp/src/client/rawsession.rs +++ b/src-tauri/vendor/russh-sftp/src/client/rawsession.rs @@ -265,6 +265,45 @@ impl RawSftpSession { Open { id, filename: filename.into(), + filename_bytes: None, + pflags: flags, + attrs, + } + .into(), + ) + .await?; + + if let Packet::Handle(_) = result { + self.handles.fetch_add(1, Ordering::SeqCst); + } + + into_with_status!(result, Handle) + } + + /// Opens a file using raw bytes for the filename (preserves original encoding). + pub async fn open_bytes( + &self, + filename_bytes: Vec, + flags: OpenFlags, + attrs: FileAttributes, + ) -> SftpResult { + if self + .limits + .open_handles + .is_some_and(|h| self.handles.load(Ordering::SeqCst) >= h) + { + return Err(Error::Limited("handle limit reached".to_owned())); + } + + let id = self.use_next_id(); + let filename = String::from_utf8_lossy(&filename_bytes).into_owned(); + let result = self + .request( + Some(id), + Open { + id, + filename, + filename_bytes: Some(filename_bytes), pflags: flags, attrs, } @@ -489,6 +528,38 @@ impl RawSftpSession { OpenDir { id, path: path.into(), + path_bytes: None, + } + .into(), + ) + .await?; + + if let Packet::Handle(_) = result { + self.handles.fetch_add(1, Ordering::SeqCst); + } + + into_with_status!(result, Handle) + } + + /// Opens a directory using raw bytes for the path (preserves original encoding). + pub async fn opendir_bytes(&self, path_bytes: Vec) -> SftpResult { + if self + .limits + .open_handles + .is_some_and(|h| self.handles.load(Ordering::SeqCst) >= h) + { + return Err(Error::Limited("Handle limit reached".to_owned())); + } + + let id = self.use_next_id(); + let path = String::from_utf8_lossy(&path_bytes).into_owned(); + let result = self + .request( + Some(id), + OpenDir { + id, + path, + path_bytes: Some(path_bytes), } .into(), ) @@ -525,6 +596,26 @@ impl RawSftpSession { Remove { id, filename: filename.into(), + filename_bytes: None, + } + .into(), + ) + .await?; + + into_status!(result) + } + + /// Removes a file using raw bytes for the filename (preserves original encoding). + pub async fn remove_bytes(&self, filename_bytes: Vec) -> SftpResult { + let id = self.use_next_id(); + let filename = String::from_utf8_lossy(&filename_bytes).into_owned(); + let result = self + .request( + Some(id), + Remove { + id, + filename, + filename_bytes: Some(filename_bytes), } .into(), ) @@ -594,6 +685,26 @@ impl RawSftpSession { Stat { id, path: path.into(), + path_bytes: None, + } + .into(), + ) + .await?; + + into_with_status!(result, Attrs) + } + + /// Queries metadata about the remote file using raw bytes (preserves original encoding). + pub async fn stat_bytes(&self, path_bytes: Vec) -> SftpResult { + let id = self.use_next_id(); + let path = String::from_utf8_lossy(&path_bytes).into_owned(); + let result = self + .request( + Some(id), + Stat { + id, + path, + path_bytes: Some(path_bytes), } .into(), ) diff --git a/src-tauri/vendor/russh-sftp/src/client/session.rs b/src-tauri/vendor/russh-sftp/src/client/session.rs index f5c93478..7ce0cad7 100644 --- a/src-tauri/vendor/russh-sftp/src/client/session.rs +++ b/src-tauri/vendor/russh-sftp/src/client/session.rs @@ -107,6 +107,11 @@ impl SftpSession { self.open_with_flags(filename, OpenFlags::READ).await } + /// Opens a file using raw bytes for the filename (preserves original encoding). + pub async fn open_bytes(&self, filename_bytes: Vec) -> SftpResult { + self.open_with_flags_bytes(filename_bytes, OpenFlags::READ).await + } + /// Opens a file in write-only mode. /// /// This function will create a file if it does not exist, and will truncate it if it does. @@ -128,6 +133,16 @@ impl SftpSession { .await } + /// Attempts to open or create the file using raw bytes (preserves original encoding). + pub async fn open_with_flags_bytes( + &self, + filename_bytes: Vec, + flags: OpenFlags, + ) -> SftpResult { + self.open_with_flags_and_attributes_bytes(filename_bytes, flags, FileAttributes::empty()) + .await + } + /// Attempts to open or create the file in the specified mode and with specified file attributes pub async fn open_with_flags_and_attributes>( &self, @@ -139,6 +154,17 @@ impl SftpSession { Ok(File::new(self.session.clone(), handle, self.features)) } + /// Attempts to open or create the file using raw bytes (preserves original encoding). + pub async fn open_with_flags_and_attributes_bytes( + &self, + filename_bytes: Vec, + flags: OpenFlags, + attributes: FileAttributes, + ) -> SftpResult { + let handle = self.session.open_bytes(filename_bytes, flags, attributes).await?.handle; + Ok(File::new(self.session.clone(), handle, self.features)) + } + /// Requests the remote party for the absolute from the relative path. pub async fn canonicalize>(&self, path: T) -> SftpResult { let name = self.session.realpath(path).await?; @@ -196,7 +222,54 @@ impl SftpSession { files = name .files .into_iter() - .map(|f| (f.filename, f.attrs)) + .map(|f| { + // Use filename_bytes if available, otherwise fall back to encoding filename as bytes + let bytes = if f.filename_bytes.is_empty() { + f.filename.as_bytes().to_vec() + } else { + f.filename_bytes + }; + (bytes, f.filename, f.attrs) + }) + .chain(files) + .collect(); + } + Err(Error::Status(status)) if status.status_code == StatusCode::Eof => break, + Err(err) => return Err(err), + } + } + + self.session.close(handle).await?; + + Ok(ReadDir { + parent, + entries: files.into(), + }) + } + + /// Returns an iterator over the entries within a directory using raw bytes for the path. + pub async fn read_dir_bytes(&self, path_bytes: Vec) -> SftpResult { + let path_str = String::from_utf8_lossy(&path_bytes).into_owned(); + let parent = Arc::from(path_str.as_str()); + + let handle = self.session.opendir_bytes(path_bytes).await?.handle; + let mut files = vec![]; + + loop { + match self.session.readdir(handle.as_str()).await { + Ok(name) => { + files = name + .files + .into_iter() + .map(|f| { + // Use filename_bytes if available, otherwise fall back to encoding filename as bytes + let bytes = if f.filename_bytes.is_empty() { + f.filename.as_bytes().to_vec() + } else { + f.filename_bytes + }; + (bytes, f.filename, f.attrs) + }) .chain(files) .collect(); } @@ -232,6 +305,11 @@ impl SftpSession { self.session.remove(filename).await.map(|_| ()) } + /// Removes a file using raw bytes for the filename (preserves original encoding). + pub async fn remove_file_bytes(&self, filename_bytes: Vec) -> SftpResult<()> { + self.session.remove_bytes(filename_bytes).await.map(|_| ()) + } + /// Rename a file or directory to a new name. pub async fn rename(&self, oldpath: O, newpath: N) -> SftpResult<()> where @@ -270,6 +348,11 @@ impl SftpSession { Ok(self.session.stat(path).await?.attrs) } + /// Queries metadata about the remote file using raw bytes (preserves original encoding). + pub async fn metadata_bytes(&self, path_bytes: Vec) -> SftpResult { + Ok(self.session.stat_bytes(path_bytes).await?.attrs) + } + /// Sets metadata for a remote file. pub async fn set_metadata>( &self, diff --git a/src-tauri/vendor/russh-sftp/src/protocol/file.rs b/src-tauri/vendor/russh-sftp/src/protocol/file.rs index 5f2eccd5..d287b5c2 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/file.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/file.rs @@ -1,20 +1,150 @@ use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::time::{Duration, UNIX_EPOCH}; use super::FileAttributes; -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Wrapper for raw bytes that uses deserialize_byte_buf +#[derive(Debug, Clone)] +struct RawBytes(Vec); + +impl Serialize for RawBytes { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for RawBytes { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct RawBytesVisitor; + + impl<'de> serde::de::Visitor<'de> for RawBytesVisitor { + type Value = RawBytes; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("raw bytes") + } + + fn visit_bytes(self, v: &[u8]) -> Result + where + E: serde::de::Error, + { + Ok(RawBytes(v.to_vec())) + } + + fn visit_byte_buf(self, v: Vec) -> Result + where + E: serde::de::Error, + { + Ok(RawBytes(v)) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut bytes = Vec::new(); + while let Some(byte) = seq.next_element()? { + bytes.push(byte); + } + Ok(RawBytes(bytes)) + } + } + + deserializer.deserialize_byte_buf(RawBytesVisitor) + } +} + +#[derive(Debug, Clone)] pub struct File { pub filename: String, + /// Raw bytes of the filename, preserving original encoding. + /// Used for serialization to send back to server. + pub filename_bytes: Vec, pub longname: String, pub attrs: FileAttributes, } +impl Serialize for File { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("File", 3)?; + // Use raw bytes for serialization to preserve original encoding + state.serialize_field("filename", &RawBytes(self.filename_bytes.clone()))?; + state.serialize_field("longname", &self.longname)?; + state.serialize_field("attrs", &self.attrs)?; + state.end() + } +} + +impl<'de> Deserialize<'de> for File { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct FileVisitor; + + impl<'de> Visitor<'de> for FileVisitor { + type Value = File; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct File") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + // Field 0: filename as raw bytes using RawBytes wrapper + let raw: RawBytes = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let filename_bytes = raw.0; + // Field 1: longname + let longname: String = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + // Field 2: attrs + let attrs: FileAttributes = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(2, &self))?; + + // Decode filename from bytes using lossy UTF-8 for display + let filename = String::from_utf8_lossy(&filename_bytes).into_owned(); + + Ok(File { + filename, + filename_bytes, + longname, + attrs, + }) + } + } + + const FIELDS: &[&str] = &["filename", "longname", "attrs"]; + deserializer.deserialize_struct("File", FIELDS, FileVisitor) + } +} + impl File { /// Omits `longname` and set dummy `attributes`. This is mainly used for [`crate::server::Handler::realpath`] as per the standard pub fn dummy>(filename: S) -> Self { + let filename = filename.into(); Self { - filename: filename.into(), + filename: filename.clone(), + filename_bytes: filename.into_bytes(), longname: "".to_string(), attrs: FileAttributes::default(), } @@ -22,8 +152,10 @@ impl File { /// Implies the use of longname pub fn new>(filename: S, attrs: FileAttributes) -> Self { + let filename = filename.into(); let mut file = Self { - filename: filename.into(), + filename: filename.clone(), + filename_bytes: filename.clone().into_bytes(), longname: "".to_string(), attrs, }; diff --git a/src-tauri/vendor/russh-sftp/src/protocol/open.rs b/src-tauri/vendor/russh-sftp/src/protocol/open.rs index 28a198f8..88c2d615 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/open.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/open.rs @@ -1,5 +1,7 @@ use std::fs; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use super::{impl_packet_for, impl_request_id, FileAttributes, Packet, RequestId}; /// Opening flags according to the specification @@ -51,13 +53,78 @@ impl From for fs::OpenOptions { } /// Implementation for `SSH_FXP_OPEN` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct Open { pub id: u32, pub filename: String, + /// Raw bytes of the filename, preserving original encoding. + /// When present, used for serialization instead of filename. + pub filename_bytes: Option>, pub pflags: OpenFlags, pub attrs: FileAttributes, } +impl Serialize for Open { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("Open", 4)?; + state.serialize_field("id", &self.id)?; + // Use raw bytes if available to preserve original encoding + match &self.filename_bytes { + Some(bytes) => state.serialize_field("filename", bytes)?, + None => state.serialize_field("filename", &self.filename)?, + } + state.serialize_field("pflags", &self.pflags)?; + state.serialize_field("attrs", &self.attrs)?; + state.end() + } +} + +impl<'de> Deserialize<'de> for Open { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct OpenVisitor; + + impl<'de> Visitor<'de> for OpenVisitor { + type Value = Open; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct Open") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let id: u32 = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?; + let filename_bytes: Vec = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(1, &self))?; + let pflags: OpenFlags = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(2, &self))?; + let attrs: FileAttributes = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(3, &self))?; + + let filename = String::from_utf8_lossy(&filename_bytes).into_owned(); + + Ok(Open { + id, + filename, + filename_bytes: Some(filename_bytes), + pflags, + attrs, + }) + } + } + + const FIELDS: &[&str] = &["id", "filename", "pflags", "attrs"]; + deserializer.deserialize_struct("Open", FIELDS, OpenVisitor) + } +} + impl_request_id!(Open); impl_packet_for!(Open); diff --git a/src-tauri/vendor/russh-sftp/src/protocol/opendir.rs b/src-tauri/vendor/russh-sftp/src/protocol/opendir.rs index 174e9045..8e15f91b 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/opendir.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/opendir.rs @@ -1,10 +1,70 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use super::{impl_packet_for, impl_request_id, Packet, RequestId}; /// Implementation for `SSH_FXP_OPENDIR` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct OpenDir { pub id: u32, pub path: String, + /// Raw bytes of the path, preserving original encoding. + /// When present, used for serialization instead of path. + pub path_bytes: Option>, +} + +impl Serialize for OpenDir { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("OpenDir", 2)?; + state.serialize_field("id", &self.id)?; + // Use raw bytes if available to preserve original encoding + match &self.path_bytes { + Some(bytes) => state.serialize_field("path", bytes)?, + None => state.serialize_field("path", &self.path)?, + } + state.end() + } +} + +impl<'de> Deserialize<'de> for OpenDir { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct OpenDirVisitor; + + impl<'de> Visitor<'de> for OpenDirVisitor { + type Value = OpenDir; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct OpenDir") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let id: u32 = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?; + let path_bytes: Vec = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(1, &self))?; + let path = String::from_utf8_lossy(&path_bytes).into_owned(); + + Ok(OpenDir { + id, + path, + path_bytes: Some(path_bytes), + }) + } + } + + const FIELDS: &[&str] = &["id", "path"]; + deserializer.deserialize_struct("OpenDir", FIELDS, OpenDirVisitor) + } } impl_request_id!(OpenDir); diff --git a/src-tauri/vendor/russh-sftp/src/protocol/remove.rs b/src-tauri/vendor/russh-sftp/src/protocol/remove.rs index ca9a0879..45f97345 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/remove.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/remove.rs @@ -1,10 +1,70 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use super::{impl_packet_for, impl_request_id, Packet, RequestId}; /// Implementation for `SSH_FXP_REMOVE` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct Remove { pub id: u32, pub filename: String, + /// Raw bytes of the filename, preserving original encoding. + /// When present, used for serialization instead of filename. + pub filename_bytes: Option>, +} + +impl Serialize for Remove { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("Remove", 2)?; + state.serialize_field("id", &self.id)?; + // Use raw bytes if available to preserve original encoding + match &self.filename_bytes { + Some(bytes) => state.serialize_field("filename", bytes)?, + None => state.serialize_field("filename", &self.filename)?, + } + state.end() + } +} + +impl<'de> Deserialize<'de> for Remove { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct RemoveVisitor; + + impl<'de> Visitor<'de> for RemoveVisitor { + type Value = Remove; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct Remove") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let id: u32 = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?; + let filename_bytes: Vec = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(1, &self))?; + let filename = String::from_utf8_lossy(&filename_bytes).into_owned(); + + Ok(Remove { + id, + filename, + filename_bytes: Some(filename_bytes), + }) + } + } + + const FIELDS: &[&str] = &["id", "filename"]; + deserializer.deserialize_struct("Remove", FIELDS, RemoveVisitor) + } } impl_request_id!(Remove); diff --git a/src-tauri/vendor/russh-sftp/src/protocol/stat.rs b/src-tauri/vendor/russh-sftp/src/protocol/stat.rs index ccc22d9d..0dee7384 100644 --- a/src-tauri/vendor/russh-sftp/src/protocol/stat.rs +++ b/src-tauri/vendor/russh-sftp/src/protocol/stat.rs @@ -1,10 +1,70 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use super::{impl_packet_for, impl_request_id, Packet, RequestId}; /// Implementation for `SSH_FXP_STAT` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct Stat { pub id: u32, pub path: String, + /// Raw bytes of the path, preserving original encoding. + /// When present, used for serialization instead of path. + pub path_bytes: Option>, +} + +impl Serialize for Stat { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("Stat", 2)?; + state.serialize_field("id", &self.id)?; + // Use raw bytes if available to preserve original encoding + match &self.path_bytes { + Some(bytes) => state.serialize_field("path", bytes)?, + None => state.serialize_field("path", &self.path)?, + } + state.end() + } +} + +impl<'de> Deserialize<'de> for Stat { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, SeqAccess, Visitor}; + use std::fmt; + + struct StatVisitor; + + impl<'de> Visitor<'de> for StatVisitor { + type Value = Stat; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct Stat") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let id: u32 = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?; + let path_bytes: Vec = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(1, &self))?; + let path = String::from_utf8_lossy(&path_bytes).into_owned(); + + Ok(Stat { + id, + path, + path_bytes: Some(path_bytes), + }) + } + } + + const FIELDS: &[&str] = &["id", "path"]; + deserializer.deserialize_struct("Stat", FIELDS, StatVisitor) + } } impl_request_id!(Stat); diff --git a/src/components/sessions/LocalTerminal.tsx b/src/components/sessions/LocalTerminal.tsx index 923d694b..097e8a91 100644 --- a/src/components/sessions/LocalTerminal.tsx +++ b/src/components/sessions/LocalTerminal.tsx @@ -19,6 +19,8 @@ interface LocalTerminalProps { setShellArgs: (v: string) => void; workingDir: string; setWorkingDir: (v: string) => void; + encoding: string; + setEncoding: (v: string) => void; } const BUILTIN_SHELL_PATHS = ["powershell.exe", "cmd.exe", "bash", "wsl.exe", "wt.exe"] as const; @@ -30,6 +32,8 @@ export function LocalTerminal({ setShellArgs, workingDir, setWorkingDir, + encoding, + setEncoding, }: LocalTerminalProps) { const { t } = useTranslation(); @@ -139,6 +143,21 @@ export function LocalTerminal({ onChange={(e) => setWorkingDir(e.target.value)} /> +
+ + +
); } diff --git a/src/components/sessions/SerialForm.tsx b/src/components/sessions/SerialForm.tsx index 3feb5ef6..4487b952 100644 --- a/src/components/sessions/SerialForm.tsx +++ b/src/components/sessions/SerialForm.tsx @@ -43,6 +43,8 @@ interface SerialFormProps { setStopBits: (v: string) => void; backspaceMode: string; setBackspaceMode: (v: string) => void; + encoding: string; + setEncoding: (v: string) => void; } function RequiredMark() { @@ -202,6 +204,8 @@ export function SerialForm({ setStopBits, backspaceMode, setBackspaceMode, + encoding, + setEncoding, }: SerialFormProps) { const { t } = useTranslation(); @@ -329,6 +333,21 @@ export function SerialForm({ +
+ + +
); } diff --git a/src/components/sessions/SshForm.tsx b/src/components/sessions/SshForm.tsx index 9887f0d9..89bc5f54 100644 --- a/src/components/sessions/SshForm.tsx +++ b/src/components/sessions/SshForm.tsx @@ -109,6 +109,8 @@ interface SshFormProps { sftpSettings: SftpSettings; setSftpSettings: (v: SftpSettings) => void; connectionId?: string; + encoding: string; + setEncoding: (v: string) => void; } function RequiredMark() { @@ -426,6 +428,8 @@ export function SshForm({ sftpSettings, setSftpSettings, connectionId, + encoding, + setEncoding, }: SshFormProps) { const { t } = useTranslation(); const [sshKeys, setSshKeys] = useState([]); @@ -905,6 +909,22 @@ export function SshForm({ +
+ + +
+ void; connectionId?: string; + encoding: string; + setEncoding: (v: string) => void; } function RequiredMark() { @@ -104,6 +106,8 @@ export function TelnetForm({ sendSga, setSendSga, connectionId, + encoding, + setEncoding, }: TelnetFormProps) { const { t } = useTranslation(); const [advancedOpen, setAdvancedOpen] = useState(false); @@ -551,6 +555,22 @@ export function TelnetForm({ + +
+ + +
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8b8a9bf8..db262f36 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -220,6 +220,10 @@ "weeks": "weeks", "years": "years" }, + "connection": { + "encoding": "Terminal Encoding", + "encodingFollowGlobal": "Follow Global Settings" + }, "credentialAutofill": { "dismiss": "Dismiss", "fill": "Fill", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 280ae485..dc3e9417 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -220,6 +220,10 @@ "weeks": "주", "years": "년" }, + "connection": { + "encoding": "인코딩", + "encodingFollowGlobal": "전역 설정 따르기" + }, "credentialAutofill": { "dismiss": "닫기", "fill": "채우기", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 541513ea..5e77a249 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -220,6 +220,10 @@ "weeks": "周", "years": "年" }, + "connection": { + "encoding": "终端编码", + "encodingFollowGlobal": "跟随全局设置" + }, "credentialAutofill": { "dismiss": "关闭", "fill": "填充", diff --git a/src/pages/NewSessionPage.tsx b/src/pages/NewSessionPage.tsx index aefa16a0..317b99a1 100644 --- a/src/pages/NewSessionPage.tsx +++ b/src/pages/NewSessionPage.tsx @@ -167,6 +167,9 @@ export default function NewSessionPage() { const [telnetSendNaws, setTelnetSendNaws] = useState(true); const [telnetSendSga, setTelnetSendSga] = useState(true); + // Per-connection encoding ("global" = follow global setting) + const [encoding, setEncoding] = useState("global"); + useEffect(() => { invoke("get_groups") .then(setGroups) @@ -204,6 +207,7 @@ export default function NewSessionPage() { serial: "serial", }; setCurrentTab(tabMap[found.type] || "ssh"); + setEncoding(found.encoding || "global"); if (found.type === "ssh") { setHost(found.host || ""); @@ -324,6 +328,7 @@ export default function NewSessionPage() { setTelnetForceCharacterAtATime(false); setTelnetSendNaws(true); setTelnetSendSga(true); + setEncoding("global"); setShowIconPicker(false); setError(""); setConnecting(false); @@ -650,6 +655,7 @@ export default function NewSessionPage() { sort_order: sortOrder, icon: iconKey || undefined, icon_auto_detect: currentTab === "ssh" ? iconAutoDetect : false, + encoding: encoding === "global" ? undefined : encoding, ...(currentTab === "ssh" ? { host: normalizedHost, @@ -1050,6 +1056,8 @@ export default function NewSessionPage() { sftpSettings={sftpSettings} setSftpSettings={setSftpSettings} connectionId={initialData?.id || editId} + encoding={encoding} + setEncoding={setEncoding} /> @@ -1061,6 +1069,8 @@ export default function NewSessionPage() { setShellArgs={setShellArgs} workingDir={workingDir} setWorkingDir={setWorkingDir} + encoding={encoding} + setEncoding={setEncoding} /> @@ -1097,6 +1107,8 @@ export default function NewSessionPage() { sendSga={telnetSendSga} setSendSga={setTelnetSendSga} connectionId={initialData?.id || editId} + encoding={encoding} + setEncoding={setEncoding} /> @@ -1120,6 +1132,8 @@ export default function NewSessionPage() { setStopBits={setStopBits} backspaceMode={serialBackspaceMode} setBackspaceMode={setSerialBackspaceMode} + encoding={encoding} + setEncoding={setEncoding} /> diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 09321230..33dbfec5 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -108,6 +108,7 @@ export interface SshConfig { post_login?: { command: string; delay_ms: number } | null; ssh_algorithms?: SshAlgorithmPreferences | null; sftp?: SftpSettings; + encoding?: string; } /** SSH authentication: none, password, or private key (PEM content). */ @@ -299,6 +300,8 @@ export interface SavedConnection { auto_login?: TelnetAutoLoginConfig; /** SSH-only: enables X11 forwarding for remote graphical applications. */ x11_forwarding?: boolean; + /** Per-connection encoding override. Empty string means follow global setting. */ + encoding?: string; } /** Stored OTP entry for two-factor authentication. */