fixed(gbk): Resolved the issue of terminal encoding not supporting GBK
This commit is contained in:
1 parent
5df8572c9f
commit
0c7d0e89db
42 files changed
+1136
-79
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -144,6 +144,7 @@ fn prepare_electerm_bookmark(
|
||||
username,
|
||||
backspace_mode: "del".to_string(),
|
||||
x11_forwarding: false,
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path,
|
||||
description: None,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -220,6 +220,10 @@
|
||||
"weeks": "weeks",
|
||||
"years": "years"
|
||||
},
|
||||
"connection": {
|
||||
"encoding": "Terminal Encoding",
|
||||
"encodingFollowGlobal": "Follow Global Settings"
|
||||
},
|
||||
"credentialAutofill": {
|
||||
"dismiss": "Dismiss",
|
||||
"fill": "Fill",
|
||||
|
||||
@@ -220,6 +220,10 @@
|
||||
"weeks": "주",
|
||||
"years": "년"
|
||||
},
|
||||
"connection": {
|
||||
"encoding": "인코딩",
|
||||
"encodingFollowGlobal": "전역 설정 따르기"
|
||||
},
|
||||
"credentialAutofill": {
|
||||
"dismiss": "닫기",
|
||||
"fill": "채우기",
|
||||
|
||||
@@ -220,6 +220,10 @@
|
||||
"weeks": "周",
|
||||
"years": "年"
|
||||
},
|
||||
"connection": {
|
||||
"encoding": "终端编码",
|
||||
"encodingFollowGlobal": "跟随全局设置"
|
||||
},
|
||||
"credentialAutofill": {
|
||||
"dismiss": "关闭",
|
||||
"fill": "填充",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Vendored
+3
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user