fixed(gbk): Resolved the issue of terminal encoding not supporting GBK

This commit is contained in:
zsc committed 2026-07-14 22:58:45 +08:00
1 parent 5df8572c9f
commit 0c7d0e89db
42 files changed
+1136 -79

No files matched your search

+3
View File
@@ -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,
+20 -3
View File
@@ -76,7 +76,10 @@ pub async fn create_temporary_ssh_session(
config: ssh::SshConfig,
create_request_id: Option<String>,
) -> AppResult<String> {
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());
+25
View File
@@ -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<String>,
#[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.
+1 -1
View File
@@ -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::{
+1
View File
@@ -144,6 +144,7 @@ fn prepare_electerm_bookmark(
username,
backspace_mode: "del".to_string(),
x11_forwarding: false,
encoding: String::new(),
},
group_path,
description: None,
+1
View File
@@ -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,
@@ -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),
+1
View File
@@ -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()),
+13 -10
View File
@@ -54,6 +54,7 @@ pub(crate) struct AutoRemoteFs {
inner: RwLock<Option<Box<dyn RemoteFs>>>,
ssh_handle: Arc<SshConnectionHandles>,
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<dyn RemoteFs> {
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<SshConnectionHandles>, String, u16, String)> {
) -> AppResult<(Arc<SshConnectionHandles>, 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::<SshConnectionHandles>()
.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::<crate::core::ssh::SshConfig>() {
(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;
+231 -42
View File
@@ -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<String> {
if is_sftp_not_found(&error) {
None
@@ -293,6 +296,11 @@ fn log_transfer_performance(
pub(crate) struct SftpBackend {
ssh_handle: Arc<SshConnectionHandles>,
identity_cache: Arc<RwLock<RemoteIdentityCache>>,
/// Cache mapping decoded paths to their raw byte representations.
/// Used to preserve original encoding for non-UTF-8 file names.
path_cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
/// 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<SshConnectionHandles>) -> Self {
pub(crate) fn new(ssh_handle: Arc<SshConnectionHandles>, 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<u8> {
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<F, G>(
max_pipeline_depth: usize,
controller: &Arc<TransferController>,
parent_controller: Option<&Arc<TransferController>>,
path_cache: &RwLock<HashMap<String, Vec<u8>>>,
mut on_bytes: F,
mut on_progress_interval: G,
) -> AppResult<u64>
@@ -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<russh_sftp::client::fs::File> = 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<Vec<FileEntry>> {
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<String>) -> 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<String>,
dirs: Vec<String>,
}
#[allow(dead_code)]
async fn collect_remove_inventory(sftp: &SftpSession, path: &str) -> AppResult<RemoveInventory> {
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<R
Ok(RemoveInventory { files, dirs })
}
#[allow(dead_code)]
async fn remove_inventory_concurrent(
pool: SftpSessionPool,
mut inventory: RemoveInventory,
@@ -2291,6 +2472,7 @@ async fn remove_inventory_concurrent(
}
impl SftpBackend {
#[allow(dead_code)]
async fn remove_dir_fast(&self, path: &str) -> 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<TransferController>,
transfer_settings: &crate::config::TransferSettings,
concurrency: SftpDirectoryConcurrency,
path_cache: Arc<RwLock<HashMap<String, Vec<u8>>>>,
) -> AppResult<DirectoryTransferSummary> {
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<AtomicU64>,
total_size: u64,
max_pipeline_depth: usize,
path_cache: &RwLock<HashMap<String, Vec<u8>>>,
) -> AppResult<u64> {
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,
+3
View File
@@ -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,
})
}
+7
View File
@@ -42,6 +42,13 @@ pub struct SshConfig {
pub ssh_algorithms: Option<SshAlgorithmPreferences>,
#[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)]
+50 -4
View File
@@ -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<SshStartupCommand>,
backspace_mode: String,
initial_notice: Option<String>,
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);
}
+4
View File
@@ -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;
});
@@ -4,6 +4,7 @@ pub struct LocalSessionConfig {
pub shell_args: String,
pub working_dir: Option<String>,
pub name: String,
pub encoding: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -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<LocalSessionConfig>,
startup_script: Option<String>,
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 {
@@ -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(),
}
}
@@ -6,4 +6,5 @@ pub struct SerialConfig {
pub stop_bits: String,
pub name: String,
pub backspace_mode: String,
pub encoding: String,
}
@@ -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,
);
@@ -8,6 +8,7 @@ fn serial_session_thread(
rt_handle: tokio::runtime::Handle,
config: SerialConfig,
connection_id: Option<String>,
encoding: String,
port: Box<dyn SerialPort>,
mut reader_port: Box<dyn SerialPort>,
) {
@@ -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);
@@ -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;
@@ -40,6 +40,7 @@ async fn telnet_session_task(
output_control_tx: mpsc::UnboundedSender<SessionCommand>,
config: TelnetSessionConfig,
connection_id: Option<String>,
encoding: String,
startup_command: Option<TelnetStartupCommand>,
) {
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);
@@ -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(),
}
}
}
+1
View File
@@ -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,
+1
View File
@@ -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"
+29 -5
View File
@@ -8,6 +8,8 @@ use crate::protocol::FileType;
pub struct DirEntry {
parent: Arc<str>,
file: String,
/// Raw bytes of the file name, preserving original encoding.
file_bytes: Vec<u8>,
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<u8> {
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<str>,
pub(crate) entries: VecDeque<(String, Metadata)>,
pub(crate) entries: VecDeque<(Vec<u8>, String, Metadata)>,
}
impl Iterator for ReadDir {
@@ -58,11 +81,12 @@ impl Iterator for ReadDir {
fn next(&mut self) -> Option<Self::Item> {
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,
}),
}
}
+111
View File
@@ -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<u8>,
flags: OpenFlags,
attrs: FileAttributes,
) -> SftpResult<Handle> {
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<u8>) -> SftpResult<Handle> {
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<u8>) -> SftpResult<Status> {
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<u8>) -> SftpResult<Attrs> {
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(),
)
+84 -1
View File
@@ -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<u8>) -> SftpResult<File> {
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<u8>,
flags: OpenFlags,
) -> SftpResult<File> {
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<T: Into<String>>(
&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<u8>,
flags: OpenFlags,
attributes: FileAttributes,
) -> SftpResult<File> {
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<T: Into<String>>(&self, path: T) -> SftpResult<String> {
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<u8>) -> SftpResult<ReadDir> {
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<u8>) -> SftpResult<()> {
self.session.remove_bytes(filename_bytes).await.map(|_| ())
}
/// Rename a file or directory to a new name.
pub async fn rename<O, N>(&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<u8>) -> SftpResult<Metadata> {
Ok(self.session.stat_bytes(path_bytes).await?.attrs)
}
/// Sets metadata for a remote file.
pub async fn set_metadata<P: Into<String>>(
&self,
+135 -3
View File
@@ -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<u8>);
impl Serialize for RawBytes {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_bytes(&self.0)
}
}
impl<'de> Deserialize<'de> for RawBytes {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(RawBytes(v.to_vec()))
}
fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(RawBytes(v))
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
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<u8>,
pub longname: String,
pub attrs: FileAttributes,
}
impl Serialize for File {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<A>(self, mut seq: A) -> Result<File, A::Error>
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<S: Into<String>>(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<S: Into<String>>(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,
};
+68 -1
View File
@@ -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<OpenFlags> 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<Vec<u8>>,
pub pflags: OpenFlags,
pub attrs: FileAttributes,
}
impl Serialize for Open {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<A>(self, mut seq: A) -> Result<Open, A::Error>
where
A: SeqAccess<'de>,
{
let id: u32 = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?;
let filename_bytes: Vec<u8> = 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);
+61 -1
View File
@@ -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<Vec<u8>>,
}
impl Serialize for OpenDir {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<A>(self, mut seq: A) -> Result<OpenDir, A::Error>
where
A: SeqAccess<'de>,
{
let id: u32 = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?;
let path_bytes: Vec<u8> = 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);
+61 -1
View File
@@ -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<Vec<u8>>,
}
impl Serialize for Remove {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<A>(self, mut seq: A) -> Result<Remove, A::Error>
where
A: SeqAccess<'de>,
{
let id: u32 = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?;
let filename_bytes: Vec<u8> = 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);
+61 -1
View File
@@ -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<Vec<u8>>,
}
impl Serialize for Stat {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<A>(self, mut seq: A) -> Result<Stat, A::Error>
where
A: SeqAccess<'de>,
{
let id: u32 = seq.next_element()?.ok_or_else(|| de::Error::invalid_length(0, &self))?;
let path_bytes: Vec<u8> = 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);
+19
View File
@@ -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)}
/>
</div>
<div>
<Label className="text-[0.6875rem] text-muted-foreground">
{t("connection.encoding")}
</Label>
<Select value={encoding} onValueChange={setEncoding}>
<SelectTrigger className="mt-1 h-8 w-full text-xs">
<SelectValue placeholder={t("connection.encodingFollowGlobal")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">{t("connection.encodingFollowGlobal")}</SelectItem>
<SelectItem value="UTF-8">UTF-8</SelectItem>
<SelectItem value="GBK">GBK</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
+19
View File
@@ -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({
</Select>
</div>
</div>
<div className="max-w-xs">
<Label className="text-xs font-medium text-foreground/80">
{t("connection.encoding")}
</Label>
<Select value={encoding} onValueChange={setEncoding}>
<SelectTrigger className="mt-1 h-8 w-full text-xs">
<SelectValue placeholder={t("connection.encodingFollowGlobal")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">{t("connection.encodingFollowGlobal")}</SelectItem>
<SelectItem value="UTF-8">UTF-8</SelectItem>
<SelectItem value="GBK">GBK</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
+20
View File
@@ -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<SshKey[]>([]);
@@ -905,6 +909,22 @@ export function SshForm({
</Tabs>
</div>
<div>
<Label className="text-xs font-medium text-foreground/80">
{t("connection.encoding")}
</Label>
<Select value={encoding} onValueChange={setEncoding}>
<SelectTrigger className="mt-1 h-8 w-full text-xs">
<SelectValue placeholder={t("connection.encodingFollowGlobal")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">{t("connection.encodingFollowGlobal")}</SelectItem>
<SelectItem value="UTF-8">UTF-8</SelectItem>
<SelectItem value="GBK">GBK</SelectItem>
</SelectContent>
</Select>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground">
<MdChevronRight
+20
View File
@@ -66,6 +66,8 @@ interface TelnetFormProps {
sendSga: boolean;
setSendSga: (v: boolean) => 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({
</div>
</DialogContent>
</Dialog>
<div>
<Label className="text-xs font-medium text-foreground/80">
{t("connection.encoding")}
</Label>
<Select value={encoding} onValueChange={setEncoding}>
<SelectTrigger className="mt-1 h-8 w-full text-xs">
<SelectValue placeholder={t("connection.encodingFollowGlobal")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">{t("connection.encodingFollowGlobal")}</SelectItem>
<SelectItem value="UTF-8">UTF-8</SelectItem>
<SelectItem value="GBK">GBK</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
+4
View File
@@ -220,6 +220,10 @@
"weeks": "weeks",
"years": "years"
},
"connection": {
"encoding": "Terminal Encoding",
"encodingFollowGlobal": "Follow Global Settings"
},
"credentialAutofill": {
"dismiss": "Dismiss",
"fill": "Fill",
+4
View File
@@ -220,6 +220,10 @@
"weeks": "주",
"years": "년"
},
"connection": {
"encoding": "인코딩",
"encodingFollowGlobal": "전역 설정 따르기"
},
"credentialAutofill": {
"dismiss": "닫기",
"fill": "채우기",
+4
View File
@@ -220,6 +220,10 @@
"weeks": "周",
"years": "年"
},
"connection": {
"encoding": "终端编码",
"encodingFollowGlobal": "跟随全局设置"
},
"credentialAutofill": {
"dismiss": "关闭",
"fill": "填充",
+14
View File
@@ -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<Group[]>("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}
/>
</TabsContent>
@@ -1061,6 +1069,8 @@ export default function NewSessionPage() {
setShellArgs={setShellArgs}
workingDir={workingDir}
setWorkingDir={setWorkingDir}
encoding={encoding}
setEncoding={setEncoding}
/>
</TabsContent>
@@ -1097,6 +1107,8 @@ export default function NewSessionPage() {
sendSga={telnetSendSga}
setSendSga={setTelnetSendSga}
connectionId={initialData?.id || editId}
encoding={encoding}
setEncoding={setEncoding}
/>
</TabsContent>
@@ -1120,6 +1132,8 @@ export default function NewSessionPage() {
setStopBits={setStopBits}
backspaceMode={serialBackspaceMode}
setBackspaceMode={setSerialBackspaceMode}
encoding={encoding}
setEncoding={setEncoding}
/>
</TabsContent>
+3
View File
@@ -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. */