diff --git a/src-tauri/crates/nyaterm-core/src/config/settings/general.rs b/src-tauri/crates/nyaterm-core/src/config/settings/general.rs index 1b4332ea..b9ae78d2 100644 --- a/src-tauri/crates/nyaterm-core/src/config/settings/general.rs +++ b/src-tauri/crates/nyaterm-core/src/config/settings/general.rs @@ -1,6 +1,10 @@ use super::super::default_true; use serde::{Deserialize, Serialize}; +fn default_rdp_client_mode() -> String { + "builtin".to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeneralSettings { #[serde(default = "default_true")] @@ -13,6 +17,8 @@ pub struct GeneralSettings { pub boss_key: Option, #[serde(default = "default_true")] pub confirm_on_close: bool, + #[serde(default = "default_rdp_client_mode")] + pub rdp_client_mode: String, } impl Default for GeneralSettings { @@ -23,6 +29,26 @@ impl Default for GeneralSettings { minimize_to_tray: false, boss_key: None, confirm_on_close: true, + rdp_client_mode: default_rdp_client_mode(), } } } + +#[cfg(test)] +mod tests { + use super::GeneralSettings; + + #[test] + fn missing_rdp_client_mode_defaults_to_builtin() { + let settings: GeneralSettings = serde_json::from_value(serde_json::json!({ + "startup_restore": true, + "startup_restore_window_layout": true, + "minimize_to_tray": false, + "boss_key": null, + "confirm_on_close": true + })) + .expect("general settings"); + + assert_eq!(settings.rdp_client_mode, "builtin"); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/storage/mod.rs b/src-tauri/crates/nyaterm-core/src/storage/mod.rs index 764f4e11..c87fa355 100644 --- a/src-tauri/crates/nyaterm-core/src/storage/mod.rs +++ b/src-tauri/crates/nyaterm-core/src/storage/mod.rs @@ -149,6 +149,12 @@ pub fn get_connection(connection_id: &str) -> AppResult AppResult> { + storage()?.get_connection(connection_id) +} + pub fn mark_connection_used(connection_id: &str) -> AppResult<()> { storage()?.mark_connection_used(connection_id) } diff --git a/src-tauri/src/cmd/rdp.rs b/src-tauri/src/cmd/rdp.rs index b308fc74..c644dbfb 100644 --- a/src-tauri/src/cmd/rdp.rs +++ b/src-tauri/src/cmd/rdp.rs @@ -1,9 +1,63 @@ +use crate::config::ConnectionType; use crate::core::rdp::{self, RdpInputEvent, RdpSessionManager}; -use crate::error::AppResult; +use crate::error::{AppError, AppResult}; +#[cfg(windows)] +use std::process::Command; use std::sync::Arc; use tauri::Emitter; use tauri::ipc::{Channel, InvokeResponseBody}; +const DEFAULT_RDP_PORT: u16 = 3389; + +fn mstsc_target(host: &str, port: u16) -> String { + let host = host.trim(); + let server = if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) { + format!("[{host}]") + } else { + host.to_string() + }; + if port == DEFAULT_RDP_PORT { + server + } else { + format!("{server}:{port}") + } +} + +#[cfg(windows)] +fn spawn_windows_rdp(target: &str) -> AppResult<()> { + Command::new("mstsc.exe") + .arg(format!("/v:{target}")) + .spawn()?; + Ok(()) +} + +#[cfg(not(windows))] +fn spawn_windows_rdp(_target: &str) -> AppResult<()> { + Err(AppError::Unsupported( + "Windows Remote Desktop is only available on Windows".to_string(), + )) +} + +#[tauri::command] +pub fn launch_windows_rdp(app: tauri::AppHandle, connection_id: String) -> AppResult<()> { + let connection = crate::storage::get_connection_without_secret(&connection_id)? + .ok_or_else(|| AppError::Config("RDP connection not found".to_string()))?; + let ConnectionType::Rdp { host, port, .. } = connection.config else { + return Err(AppError::Config( + "Connection is not an RDP connection".to_string(), + )); + }; + let target = mstsc_target(&host, port); + spawn_windows_rdp(&target)?; + + if let Err(error) = crate::storage::mark_connection_used(&connection_id) { + tracing::warn!(connection_id, %error, "Failed to mark RDP connection as recently used"); + } else { + let _ = app.emit("connections-changed", ()); + } + Ok(()) +} + #[tauri::command] pub async fn create_rdp_session( app: tauri::AppHandle, @@ -99,3 +153,29 @@ pub async fn respond_rdp_certificate( .respond_certificate(&request_id, accepted, remember) .await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mstsc_target_formats_hosts_and_non_default_ports() { + assert_eq!( + mstsc_target("server.example.com", 3389), + "server.example.com" + ); + assert_eq!(mstsc_target("192.0.2.10", 3390), "192.0.2.10:3390"); + assert_eq!(mstsc_target("2001:db8::1", 3389), "[2001:db8::1]"); + assert_eq!(mstsc_target("2001:db8::1", 3390), "[2001:db8::1]:3390"); + assert_eq!(mstsc_target("[2001:db8::1]", 3390), "[2001:db8::1]:3390"); + } + + #[cfg(not(windows))] + #[test] + fn system_rdp_launch_is_explicitly_unsupported_off_windows() { + assert!(matches!( + spawn_windows_rdp("server.example.com"), + Err(AppError::Unsupported(_)) + )); + } +} diff --git a/src-tauri/src/core/recording/mod.rs b/src-tauri/src/core/recording/mod.rs index 8f408b00..884a0168 100644 --- a/src-tauri/src/core/recording/mod.rs +++ b/src-tauri/src/core/recording/mod.rs @@ -4,7 +4,6 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; use std::fs::{self, File, OpenOptions}; use std::io::{BufWriter, Write}; -use std::mem; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{self, SyncSender, TrySendError}; diff --git a/src-tauri/src/core/recording/search.rs b/src-tauri/src/core/recording/search.rs index 756b3803..a6850218 100644 --- a/src-tauri/src/core/recording/search.rs +++ b/src-tauri/src/core/recording/search.rs @@ -118,31 +118,34 @@ fn context_records( #[cfg(test)] fn strip_terminal_control_sequences(text: &str) -> String { - let replayed = replay_terminal_output(text, "", 0); + let mut pending = Vec::new(); + let mut cursor = 0; + let replayed = replay_terminal_output(text, &mut pending, &mut cursor); let mut out = String::with_capacity(text.len()); for line in replayed.lines { out.push_str(&line); out.push('\n'); } - out.push_str(&replayed.tail); + out.extend(pending); out } struct TerminalReplayResult { lines: Vec, - tail: String, - cursor: usize, + // Echo matching must stop at the first artificial line boundary. + first_forced_split: Option, } fn replay_terminal_output( text: &str, - initial_line: &str, - initial_cursor: usize, + line: &mut Vec, + cursor: &mut usize, ) -> TerminalReplayResult { let bytes = text.as_bytes(); - let mut line = initial_line.chars().collect::>(); - let mut cursor = initial_cursor.min(line.len()); - let mut lines = Vec::new(); + let mut replayed = TerminalReplayResult { + lines: Vec::new(), + first_forced_split: None, + }; let mut i = 0; while i < bytes.len() { @@ -163,9 +166,9 @@ fn replay_terminal_output( apply_csi_sequence( &text[params_start..i - 1], b, - &mut line, - &mut cursor, - &mut lines, + line, + cursor, + &mut replayed.lines, ); break; } @@ -201,28 +204,26 @@ fn replay_terminal_output( } } b'\r' => { - cursor = 0; + *cursor = 0; i += 1; } b'\n' => { - lines.push(line.iter().collect()); - line.clear(); - cursor = 0; + replayed.lines.push(take_replay_line(line, cursor)); i += 1; } b'\t' => { - write_terminal_char(&mut line, &mut cursor, '\t'); + write_terminal_char(line, cursor, '\t', &mut replayed); i += 1; } b'\x08' => { - cursor = cursor.saturating_sub(1); + *cursor = cursor.saturating_sub(1); i += 1; } b if b.is_ascii_control() => { i += 1; } b if b.is_ascii() => { - write_terminal_char(&mut line, &mut cursor, b as char); + write_terminal_char(line, cursor, b as char, &mut replayed); i += 1; } _ => { @@ -233,24 +234,36 @@ fn replay_terminal_output( let Some(ch) = text[i..].chars().next() else { break; }; - write_terminal_char(&mut line, &mut cursor, ch); + write_terminal_char(line, cursor, ch, &mut replayed); i += ch.len_utf8(); } } } - TerminalReplayResult { - lines, - tail: line.iter().collect(), - cursor, - } + replayed } const MAX_REPLAY_CURSOR_COLUMNS: usize = 4096; +// Transcript segments are bounded even when a TUI never sends a newline. +const MAX_REPLAY_LINE_CHARS: usize = 16 * 1024; -fn write_terminal_char(line: &mut Vec, cursor: &mut usize, ch: char) { - while *cursor > line.len() { - line.push(' '); +fn take_replay_line(line: &mut Vec, cursor: &mut usize) -> String { + *cursor = 0; + line.drain(..).collect() +} + +fn write_terminal_char( + line: &mut Vec, + cursor: &mut usize, + ch: char, + replayed: &mut TerminalReplayResult, +) { + if *cursor >= MAX_REPLAY_LINE_CHARS { + replayed.first_forced_split.get_or_insert(replayed.lines.len()); + replayed.lines.push(take_replay_line(line, cursor)); + } + if *cursor > line.len() { + line.resize(*cursor, ' '); } if *cursor == line.len() { @@ -270,12 +283,9 @@ fn apply_csi_sequence( ) { match final_byte { b'B' | b'E' => { - let count = csi_param(params, 0, 1).max(1); - for _ in 0..count { - lines.push(line.iter().collect()); - line.clear(); - } - *cursor = 0; + // Empty transcript records are discarded; do not expand cursor + // movement into a parameter-sized collection of blank rows. + lines.push(take_replay_line(line, cursor)); } b'C' => { let count = csi_param(params, 0, 1); @@ -316,10 +326,10 @@ fn apply_csi_sequence( } } b'@' => { - let count = csi_param(params, 0, 1); - for _ in 0..count { - line.insert((*cursor).min(line.len()), ' '); - } + let count = csi_param(params, 0, 1) + .min(MAX_REPLAY_LINE_CHARS.saturating_sub(line.len())); + let start = (*cursor).min(line.len()); + line.splice(start..start, std::iter::repeat_n(' ', count)); } _ => {} } diff --git a/src-tauri/src/core/recording/session_capture.rs b/src-tauri/src/core/recording/session_capture.rs index 136165d4..0bab3858 100644 --- a/src-tauri/src/core/recording/session_capture.rs +++ b/src-tauri/src/core/recording/session_capture.rs @@ -2,7 +2,7 @@ struct TranscriptState { records: VecDeque, record_bytes: usize, memory_limit_bytes: usize, - output_buffer: String, + output_buffer: Vec, output_cursor: usize, submitted_line_echo: Option, next_line_id: u64, @@ -14,7 +14,7 @@ impl TranscriptState { records: VecDeque::new(), record_bytes: 0, memory_limit_bytes, - output_buffer: String::new(), + output_buffer: Vec::new(), output_cursor: 0, submitted_line_echo: None, next_line_id: 1, @@ -50,14 +50,23 @@ impl TranscriptState { return Vec::new(); } - let replayed = replay_terminal_output(data, &self.output_buffer, self.output_cursor); - self.output_buffer = replayed.tail; - self.output_cursor = replayed.cursor; - + let replayed = replay_terminal_output( + data, + &mut self.output_buffer, + &mut self.output_cursor, + ); + let first_forced_split = replayed.first_forced_split; replayed .lines .into_iter() - .filter_map(|line| self.append_output_line(line)) + .enumerate() + .filter_map(|(index, line)| { + if Some(index) == first_forced_split { + // A segment ending in the command is not proof of an echo. + self.submitted_line_echo = None; + } + self.append_output_line(line) + }) .collect() } @@ -93,11 +102,8 @@ impl TranscriptState { fn flush_output_lines(&mut self, flush_partial: bool) -> Vec { let mut flushed = Vec::new(); - if flush_partial && !self.output_buffer.is_empty() { - let tail = mem::take(&mut self.output_buffer) - .trim_end_matches('\r') - .to_string(); - self.output_cursor = 0; + if flush_partial { + let tail = take_replay_line(&mut self.output_buffer, &mut self.output_cursor); if let Some(record) = self.append_output_line(tail) { flushed.push(record); } @@ -137,14 +143,7 @@ impl TranscriptState { } fn flush_output_before_command(&mut self) -> Vec { - if self.output_buffer.is_empty() { - return Vec::new(); - } - - let tail = mem::take(&mut self.output_buffer) - .trim_end_matches('\r') - .to_string(); - self.output_cursor = 0; + let tail = take_replay_line(&mut self.output_buffer, &mut self.output_cursor); if looks_like_prompt_tail(&tail) { return Vec::new(); diff --git a/src-tauri/src/core/recording/tests.rs b/src-tauri/src/core/recording/tests.rs index 2fc910d3..e0ec0399 100644 --- a/src-tauri/src/core/recording/tests.rs +++ b/src-tauri/src/core/recording/tests.rs @@ -120,6 +120,220 @@ mod tests { assert!(!cleaned.contains("CoderKangMode")); } + #[test] + fn segments_long_output_in_one_chunk_without_losing_unicode() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let output = "你🙂".repeat(super::MAX_REPLAY_LINE_CHARS + 3); + + let mut records = transcript.write_output(&output); + + assert_eq!(records.len(), 2); + assert!(records.iter().all(|record| { + record.data.chars().count() == super::MAX_REPLAY_LINE_CHARS + })); + records.extend(transcript.finish()); + let restored: String = records.iter().map(|record| record.data.as_str()).collect(); + assert_eq!(restored, output); + } + + #[test] + fn segments_long_output_across_chunks_with_bounded_pending_line() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let chunk = "x".repeat(257); + let mut records = Vec::new(); + + for _ in 0..256 { + records.extend(transcript.write_output(&chunk)); + assert!(transcript.output_buffer.len() <= super::MAX_REPLAY_LINE_CHARS); + } + records.extend(transcript.finish()); + + assert!(records.iter().all(|record| { + record.data.chars().count() <= super::MAX_REPLAY_LINE_CHARS + })); + let restored: String = records.iter().map(|record| record.data.as_str()).collect(); + assert_eq!(restored, chunk.repeat(256)); + } + + #[test] + fn keeps_full_pending_line_editable_until_it_needs_another_segment() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let full_line = "x".repeat(super::MAX_REPLAY_LINE_CHARS); + + assert!(transcript.write_output(&full_line).is_empty()); + assert!(transcript.write_output("\rZ").is_empty()); + let edited = transcript.write_output("\n"); + assert_eq!(edited.len(), 1); + assert_eq!(edited[0].data, format!("Z{}", &full_line[1..])); + + assert!(transcript.write_output(&full_line).is_empty()); + let segments = transcript.write_output("!"); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].data, full_line); + assert_eq!(transcript.finish()[0].data, "!"); + } + + #[test] + fn preserves_split_output_ending_with_a_pending_command() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.record_command("pwd".to_string()); + let output = format!("{}pwdTAIL", "x".repeat(super::MAX_REPLAY_LINE_CHARS - 3)); + + let records = transcript.write_output(&format!("{output}\n")); + + assert_eq!(records.len(), 2); + let restored: String = records.iter().map(|record| record.data.as_str()).collect(); + assert_eq!(restored, output); + } + + #[test] + fn filters_complete_command_echo_before_later_forced_splits() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.record_command("pwd".to_string()); + let output = format!("{}tail", "x".repeat(super::MAX_REPLAY_LINE_CHARS)); + + let records = transcript.write_output(&format!("pwd\n{output}\n")); + + assert_eq!(records.len(), 2); + let restored: String = records.iter().map(|record| record.data.as_str()).collect(); + assert_eq!(restored, output); + } + + #[test] + fn reuses_pending_line_storage_across_chunks_and_flushes() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.output_buffer.reserve(128); + let allocation = transcript.output_buffer.as_ptr(); + let capacity = transcript.output_buffer.capacity(); + + for _ in 0..64 { + assert!(transcript.write_output("x").is_empty()); + assert_eq!(transcript.output_buffer.as_ptr(), allocation); + } + assert_eq!(transcript.write_output("\n")[0].data, "x".repeat(64)); + assert_eq!(transcript.output_buffer.capacity(), capacity); + transcript.write_output("next"); + assert_eq!(transcript.output_buffer.as_ptr(), allocation); + } + + #[test] + fn preserves_cursor_position_between_output_chunks() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.write_output("abc"); + transcript.write_output("\x1b[8G"); + + let records = transcript.write_output("X\n"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].data, "abc X"); + } + + #[test] + fn resets_cursor_when_flushing_an_empty_pending_line() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.write_output("\x1b[8G"); + assert!(transcript.finish().is_empty()); + assert_eq!(transcript.write_output("first\n")[0].data, "first"); + + transcript.write_output("\x1b[8G"); + transcript.snapshot_records(); + assert_eq!(transcript.write_output("second\n")[0].data, "second"); + + transcript.write_output("\x1b[8G"); + transcript.record_command("echo third".to_string()); + assert_eq!(transcript.write_output("third\n")[0].data, "third"); + } + + #[test] + fn bounds_csi_insert_expansion_without_losing_existing_text() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.write_output("tail"); + let insert = format!("\r\x1b[{}@", usize::MAX); + + assert!(transcript.write_output(&insert).is_empty()); + assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_LINE_CHARS); + assert!(transcript.write_output(&insert).is_empty()); + assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_LINE_CHARS); + + let records = transcript.write_output("ok\nnext\n"); + assert_eq!(records.len(), 2); + assert!(records[0].data.starts_with("ok")); + assert!(records[0].data.ends_with("tail")); + assert_eq!(records[0].data.chars().count(), super::MAX_REPLAY_LINE_CHARS); + assert_eq!(records[1].data, "next"); + } + + #[test] + fn bounds_csi_vertical_movement_without_expanding_empty_rows() { + for final_byte in ['B', 'E'] { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let output = format!("before\x1b[{}{final_byte}after\n", usize::MAX); + + let records = transcript.write_output(&output); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].data, "before"); + assert_eq!(records[1].data, "after"); + assert!(transcript.output_buffer.is_empty()); + } + } + + #[test] + fn bounds_csi_horizontal_movement_and_deletion() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + transcript.write_output(&format!("\x1b[{}C", usize::MAX)); + transcript.write_output("X"); + assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_CURSOR_COLUMNS + 1); + + let records = transcript.write_output(&format!( + "\x1b[{}D\x1b[{}Pdone\n", usize::MAX, usize::MAX + )); + assert_eq!(records.len(), 1); + assert_eq!(records[0].data, "done"); + } + + #[test] + fn bounds_repeated_tui_redraws_without_linefeeds() { + let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES); + let text = "x".repeat(200); + let chunk = format!("\x1b[1;1H\x1b[32m{text}\x1b[0m"); + let mut recorded_chars = 0; + + for _ in 0..2048 { + for record in transcript.write_output(&chunk) { + assert!(record.data.len() <= super::MAX_REPLAY_LINE_CHARS); + recorded_chars += record.data.len(); + } + assert!(transcript.output_buffer.len() <= super::MAX_REPLAY_LINE_CHARS); + } + for record in transcript.finish() { + recorded_chars += record.data.len(); + } + assert_eq!(recorded_chars, text.len() * 2048); + } + + #[test] + fn searches_output_after_long_unterminated_text() { + let manager = RecordingManager::new(); + manager.write_output("s1", &"x".repeat(super::MAX_REPLAY_LINE_CHARS * 3)); + manager.write_output("s1", "tail-sentinel\n"); + + let result = manager.search_history(super::TerminalHistorySearchRequest { + session_id: "s1".to_string(), + query: "tail-sentinel".to_string(), + case_sensitive: true, + regex: false, + whole_word: false, + limit: None, + context_before: None, + context_after: None, + max_lines: None, + }).unwrap(); + + assert_eq!(result.total, 1); + assert_eq!(result.results[0].preview, "tail-sentinel"); + } + #[test] fn consumes_matching_echo_prefix() { let mut prefix = "ps -ef".to_string(); @@ -571,13 +785,16 @@ mod tests { let path = manager .start_with_profile(session_id, context, profile, None) .unwrap(); - manager.write_raw_output(session_id, b"\x1b[31mERROR\x1b[0m\n"); - manager.write_output(session_id, "\x1b[31mERROR\x1b[0m\n"); + let output = format!( + "\x1b[31mERROR\x1b[0m\n{}", + "x".repeat(super::MAX_REPLAY_LINE_CHARS * 2 + 3), + ); + manager.write_raw_output(session_id, output.as_bytes()); + manager.write_output(session_id, &output); manager.stop(session_id).unwrap(); let raw = fs::read(&path).unwrap(); - assert!(raw.windows(5).any(|window| window == b"ERROR")); - assert!(raw.contains(&0x1b)); + assert_eq!(raw, output.as_bytes()); let transcript_path = unique_path("raw-transcript"); manager diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7db50334..ea4511e5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -261,6 +261,7 @@ pub fn run() { cmd::session::create_telnet_session, cmd::session::create_serial_session, cmd::rdp::create_rdp_session, + cmd::rdp::launch_windows_rdp, cmd::rdp::rdp_attach_frame_channel, cmd::rdp::rdp_input_batch, cmd::rdp::rdp_set_keyboard_capture, diff --git a/src/App.tsx b/src/App.tsx index d8c2fe3a..e187dd85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,7 @@ import { getRemoteDesktopPaneDisplay, getTemporaryLinkSessionType, isSessionCreationCancelled, + launchSavedRdpWithSystemClient, type StartupCommandRequest, sendStartupCommandToSession, } from "./lib/appSessionFactory"; @@ -488,6 +489,7 @@ function App() { setActiveTabId, updatePaneSession, updateTabSession, + rdpClientMode: appSettings.general.rdp_client_mode, setTerminalWindows, updateAutoIconForSessionStart, }); @@ -645,6 +647,33 @@ function App() { onSuccess?: (sessionId: string) => void; }, ) => { + try { + if ( + await launchSavedRdpWithSystemClient( + connection, + appSettings.general.rdp_client_mode, + ) + ) { + recordRecentConnection(connection.id); + return; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + logger.error({ + domain: "session.lifecycle", + event: "connection.external_rdp_open_failed", + message: + options?.failureContext ?? "Windows Remote Desktop launch failed", + ids: { connection_id: connection.id }, + error, + }); + toast.error( + t("savedConnections.connectionFailed", { error: errorMessage }), + ); + if (options?.propagateError) throw error; + return; + } + const pending = addPendingTab( connection.name, getConnectionSessionType(connection), @@ -699,6 +728,7 @@ function App() { }, [ addPendingTab, + appSettings.general.rdp_client_mode, hasTab, markTabConnectionFailed, maybePromptConnectionEdit, @@ -1090,6 +1120,31 @@ function App() { const anchorTabId = targetLeaf?.activeTabId ?? targetLeaf?.tabIds[targetLeaf.tabIds.length - 1] ?? null; + try { + if ( + await launchSavedRdpWithSystemClient( + connection, + appSettings.general.rdp_client_mode, + ) + ) { + recordRecentConnection(connection.id); + return; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + logger.error({ + domain: "session.lifecycle", + event: "connection.external_rdp_open_failed", + message: "Windows Remote Desktop launch failed from tab menu", + ids: { connection_id: connection.id }, + error, + }); + toast.error( + t("savedConnections.connectionFailed", { error: errorMessage }), + ); + return; + } + if (targetLeaf?.activeTabId) { handleSelectLeafTab(leafId, targetLeaf.activeTabId); } @@ -1151,6 +1206,7 @@ function App() { }, [ addPendingTab, + appSettings.general.rdp_client_mode, hasTab, handleSelectLeafTab, setTerminalWindows, diff --git a/src/components/panel/QuickCommands.test.tsx b/src/components/panel/QuickCommands.test.tsx new file mode 100644 index 00000000..cc6d5dce --- /dev/null +++ b/src/components/panel/QuickCommands.test.tsx @@ -0,0 +1,276 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + QuickCommand, + QuickCommandCategory, + QuickCommandsConfig, + QuickCommandViewMode, +} from "@/types/global"; +import QuickCommands from "./QuickCommands"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + listen: vi.fn(async () => vi.fn()), + openQuickCommand: vi.fn(), + writeClipboardText: vi.fn(async () => undefined), +})); + +let appState: { + appSettings: { + ui: { + quick_cmd_category_width: number; + quick_cmd_view_mode: QuickCommandViewMode; + quick_cmd_sort_mode: "created"; + quick_cmd_selected_category: string; + }; + }; + updateUi: ReturnType; +}; + +let config: QuickCommandsConfig; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, options?: Record) => { + const labels: Record = { + "quickCommands.addCategory": "Add Category", + "quickCommands.addCommand": "Add Command", + "quickCommands.allCategories": "All Categories", + "quickCommands.uncategorized": "Uncategorized", + "quickCommands.edit": "Edit", + "quickCommands.copyCommand": "Copy command", + "quickCommands.sendToAll": "Send to all", + "quickCommands.delete": "Delete", + "quickCommands.noCommandsFound": "No commands found", + "quickCommands.newCategoryRootHint": "Create at root", + "quickCommands.newCategoryParentHint": `Create under ${String(options?.category ?? "")}`, + "quickCommands.categoryName": "Category name", + "quickCommands.categoryPlaceholder": "Category name", + "common.cancel": "Cancel", + "common.confirm": "Confirm", + }; + return labels[key] ?? key; + }, + }), +})); + +vi.mock("@/context/AppContext", () => ({ + useApp: () => appState, +})); + +vi.mock("@/lib/invoke", () => ({ + invoke: mocks.invoke, +})); + +vi.mock("@/lib/windowManager", () => ({ + openQuickCommand: mocks.openQuickCommand, +})); + +vi.mock("@/lib/clipboard", () => ({ + writeClipboardText: mocks.writeClipboardText, +})); + +vi.mock("@/lib/aiEvents", () => ({ + openAIAssistant: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: mocks.listen, +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), + save: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn(), +})); + +const dockerCategory: QuickCommandCategory = { + id: "docker", + name: "Docker", +}; + +const deployCommand: QuickCommand = { + id: "deploy", + label: "Deploy", + command: "docker compose up -d", + category_id: dockerCategory.id, + execution_mode: "execute", +}; + +function setUi(viewMode: QuickCommandViewMode, selectedCategory: string) { + appState = { + appSettings: { + ui: { + quick_cmd_category_width: 176, + quick_cmd_view_mode: viewMode, + quick_cmd_sort_mode: "created", + quick_cmd_selected_category: selectedCategory, + }, + }, + updateUi: vi.fn(), + }; +} + +function renderQuickCommands(options?: { + viewMode?: QuickCommandViewMode; + selectedCategory?: string; + commands?: QuickCommand[]; + categories?: QuickCommandCategory[]; +}) { + setUi(options?.viewMode ?? "list", options?.selectedCategory ?? "all"); + config = { + commands: options?.commands ?? [deployCommand], + categories: options?.categories ?? [dockerCategory], + }; + const onSend = vi.fn(); + const onSendToAll = vi.fn(); + const view = render( + , + ); + return { ...view, onSend, onSendToAll }; +} + +async function waitForLoadedCommand() { + await screen.findByText(deployCommand.label); +} + +async function waitForLoadedCategories() { + await screen.findByText(dockerCategory.name); +} + +describe("QuickCommands context actions", () => { + beforeEach(() => { + mocks.invoke.mockReset(); + mocks.openQuickCommand.mockReset(); + mocks.writeClipboardText.mockReset(); + mocks.writeClipboardText.mockResolvedValue(undefined); + mocks.invoke.mockImplementation((command: string) => { + if (command === "get_quick_commands") return Promise.resolve(config); + return Promise.resolve(undefined); + }); + }); + + it.each(["list", "compact", "tile"] as const)( + "distinguishes command and blank context menus in %s view", + async (viewMode) => { + const { onSend, onSendToAll } = renderQuickCommands({ viewMode }); + await waitForLoadedCommand(); + + fireEvent.contextMenu(screen.getByText(deployCommand.label)); + const edit = await screen.findByText("Edit"); + const itemMenu = edit.closest('[data-slot="context-menu-content"]'); + expect(itemMenu).not.toBeNull(); + expect( + Array.from(itemMenu?.querySelectorAll('[role="menuitem"]') ?? []).map( + (item) => item.textContent?.trim(), + ), + ).toEqual(["Edit", "Copy command", "Send to all", "Delete"]); + + fireEvent.click(screen.getByText("Copy command")); + await waitFor(() => { + expect(mocks.writeClipboardText).toHaveBeenCalledWith( + deployCommand.command, + ); + }); + expect(onSend).not.toHaveBeenCalled(); + expect(onSendToAll).not.toHaveBeenCalled(); + + fireEvent.contextMenu(screen.getByTestId("quick-command-pane")); + const addCommand = await screen.findByText("Add Command"); + expect(screen.queryByText("Edit")).toBeNull(); + fireEvent.click(addCommand); + expect(mocks.openQuickCommand).toHaveBeenCalledWith(undefined, { + categoryId: null, + }); + }, + ); + + it("resolves command context from an SVG descendant", async () => { + renderQuickCommands(); + await waitForLoadedCommand(); + + const commandItem = screen + .getByText(deployCommand.label) + .closest("[data-quick-command-id]"); + const icon = commandItem?.querySelector("svg"); + expect(icon).not.toBeNull(); + + fireEvent.contextMenu(icon!); + const edit = await screen.findByText("Edit"); + const itemMenu = edit.closest('[data-slot="context-menu-content"]'); + expect(itemMenu).not.toBeNull(); + expect( + Array.from(itemMenu?.querySelectorAll('[role="menuitem"]') ?? []).map( + (item) => item.textContent?.trim(), + ), + ).toEqual(["Edit", "Copy command", "Send to all", "Delete"]); + }); + + it("inherits the selected saved category from toolbar and empty-state creation", async () => { + renderQuickCommands({ + selectedCategory: dockerCategory.id, + commands: [], + }); + await waitForLoadedCategories(); + + const addButtons = screen.getAllByRole("button", { name: "Add Command" }); + fireEvent.click(addButtons[0]); + expect(mocks.openQuickCommand).toHaveBeenLastCalledWith(undefined, { + categoryId: dockerCategory.id, + }); + + fireEvent.click(addButtons[addButtons.length - 1]); + expect(mocks.openQuickCommand).toHaveBeenLastCalledWith(undefined, { + categoryId: dockerCategory.id, + }); + + fireEvent.contextMenu(screen.getByTestId("quick-command-pane")); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Add Command" }), + ); + expect(mocks.openQuickCommand).toHaveBeenLastCalledWith(undefined, { + categoryId: dockerCategory.id, + }); + }); + + it.each(["all", "uncategorized", "missing-category"])( + "does not force a category for %s selection", + async (selectedCategory) => { + renderQuickCommands({ selectedCategory, commands: [] }); + await waitForLoadedCategories(); + + fireEvent.click( + screen.getAllByRole("button", { name: "Add Command" })[0], + ); + expect(mocks.openQuickCommand).toHaveBeenLastCalledWith(undefined, { + categoryId: null, + }); + }, + ); + + it("creates a root category from the sidebar blank area", async () => { + renderQuickCommands(); + await waitForLoadedCommand(); + + fireEvent.contextMenu( + screen.getByTestId("quick-command-category-empty-area"), + ); + fireEvent.click(await screen.findByText("Add Category")); + expect(await screen.findByText("Create at root")).not.toBeNull(); + }); + + it("preserves Add Command on a saved category", async () => { + renderQuickCommands(); + await waitForLoadedCommand(); + + fireEvent.contextMenu(screen.getByText(dockerCategory.name)); + fireEvent.click(await screen.findByText("Add Command")); + expect(mocks.openQuickCommand).toHaveBeenCalledWith(undefined, { + categoryId: dockerCategory.id, + }); + }); +}); diff --git a/src/components/panel/QuickCommands.tsx b/src/components/panel/QuickCommands.tsx index 03217064..b3ec2a1a 100644 --- a/src/components/panel/QuickCommands.tsx +++ b/src/components/panel/QuickCommands.tsx @@ -4,7 +4,15 @@ import { downloadJson } from "@/lib/backend/browserArtifacts"; import { listen } from "@/lib/backend/api"; import { save as saveFileDialog } from "@/lib/backend/platform/dialog"; import { MoreHorizontalIcon } from "lucide-react"; -import { type DragEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type DragEvent, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { BiExport, BiImport } from "react-icons/bi"; import { BsFillSendPlusFill } from "react-icons/bs"; @@ -66,8 +74,17 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useApp } from "@/context/AppContext"; import { openAIAssistant } from "@/lib/aiEvents"; import { writeClipboardText } from "@/lib/clipboard"; @@ -147,7 +164,8 @@ const QUICK_COMMAND_DRAG_MIME = "application/x-nyaterm-quick-command"; function clampQuickCommandCategoryWidth(width: unknown) { const numericWidth = typeof width === "number" ? width : Number(width); - if (!Number.isFinite(numericWidth)) return QUICK_COMMAND_CATEGORY_WIDTH_DEFAULT; + if (!Number.isFinite(numericWidth)) + return QUICK_COMMAND_CATEGORY_WIDTH_DEFAULT; return Math.max( QUICK_COMMAND_CATEGORY_WIDTH_MIN, Math.min(QUICK_COMMAND_CATEGORY_WIDTH_MAX, Math.round(numericWidth)), @@ -155,7 +173,9 @@ function clampQuickCommandCategoryWidth(width: unknown) { } function normalizeQuickCommandViewMode(mode: unknown): QuickCommandViewMode { - return mode === "list" || mode === "compact" || mode === "tile" ? mode : "tile"; + return mode === "list" || mode === "compact" || mode === "tile" + ? mode + : "tile"; } function NewQuickCommandCategoryDialog({ @@ -177,7 +197,11 @@ function NewQuickCommandCategoryDialog({ const trimmedName = name.trim(); const hasDuplicateName = draft - ? hasQuickCommandCategorySiblingName(categories, draft.parentId, trimmedName) + ? hasQuickCommandCategorySiblingName( + categories, + draft.parentId, + trimmedName, + ) : false; const errorMessage = submitted && !trimmedName @@ -194,7 +218,11 @@ function NewQuickCommandCategoryDialog({ if (!open) onCancel(); }} > - +
{ @@ -205,7 +233,9 @@ function NewQuickCommandCategoryDialog({ }} > - {t("quickCommands.addCategory")} + + {t("quickCommands.addCategory")} + {parentLabel ? t("quickCommands.newCategoryParentHint", { @@ -224,7 +254,9 @@ function NewQuickCommandCategoryDialog({ {t("quickCommands.categoryName")} {errorMessage && ( - {errorMessage} + + {errorMessage} + )} { @@ -261,7 +294,9 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const { t } = useTranslation(); const { appSettings, updateUi } = useApp(); const [commands, setCommands] = useState([]); - const [savedCategories, setSavedCategories] = useState([]); + const [savedCategories, setSavedCategories] = useState< + QuickCommandCategory[] + >([]); const [quickCommandsLoaded, setQuickCommandsLoaded] = useState(false); const loaded = useRef(false); const saveTimerRef = useRef | null>(null); @@ -273,18 +308,29 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const [aiPrompt, setAiPrompt] = useState(""); const [aiPopoverOpen, setAiPopoverOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); - const [newCategoryDraft, setNewCategoryDraft] = useState( + const [newCategoryDraft, setNewCategoryDraft] = + useState(null); + const [expandedCategoryIds, setExpandedCategoryIds] = useState>( + () => new Set(), + ); + const [categoryToRename, setCategoryToRename] = + useState(null); + const [categoryToDelete, setCategoryToDelete] = + useState(null); + const [commandToDelete, setCommandToDelete] = useState( + null, + ); + const [contextCommandId, setContextCommandId] = useState(null); + const [draggingCategoryId, setDraggingCategoryId] = useState( null, ); - const [expandedCategoryIds, setExpandedCategoryIds] = useState>(() => new Set()); - const [categoryToRename, setCategoryToRename] = useState(null); - const [categoryToDelete, setCategoryToDelete] = useState(null); - const [commandToDelete, setCommandToDelete] = useState(null); - const [draggingCategoryId, setDraggingCategoryId] = useState(null); const [categoryDragTarget, setCategoryDragTarget] = useState(null); - const [draggingCommandId, setDraggingCommandId] = useState(null); - const [commandDragTarget, setCommandDragTarget] = useState(null); + const [draggingCommandId, setDraggingCommandId] = useState( + null, + ); + const [commandDragTarget, setCommandDragTarget] = + useState(null); const categoryDragSourceRef = useRef(null); const commandDragSourceRef = useRef(null); @@ -340,13 +386,18 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const handleConfirmDeleteCategory = useCallback(() => { if (!categoryToDelete) return; - const currentCategories = buildQuickCommandCategoryList(savedCategories, commands); + const currentCategories = buildQuickCommandCategoryList( + savedCategories, + commands, + ); const { deleteIds } = deleteQuickCommandCategoryTree( currentCategories, commands, categoryToDelete.id, ); - setSavedCategories((prev) => prev.filter((category) => !deleteIds.has(category.id))); + setSavedCategories((prev) => + prev.filter((category) => !deleteIds.has(category.id)), + ); setCommands((prev) => prev.filter((cmd) => !cmd.category_id || !deleteIds.has(cmd.category_id)), ); @@ -358,7 +409,8 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma return next; }); updateUi((current) => - current.quick_cmd_selected_category && deleteIds.has(current.quick_cmd_selected_category) + current.quick_cmd_selected_category && + deleteIds.has(current.quick_cmd_selected_category) ? { quick_cmd_selected_category: "all" } : {}, ); @@ -371,7 +423,9 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const renamedCategory = { ...categoryToRename, name }; setSavedCategories((prev) => { - const exists = prev.some((category) => category.id === renamedCategory.id); + const exists = prev.some( + (category) => category.id === renamedCategory.id, + ); return exists ? prev.map((category) => category.id === renamedCategory.id ? renamedCategory : category, @@ -412,14 +466,21 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma openQuickCommand(undefined, { categoryId }); }, []); - const handleMoveCategory = useCallback((categoryId: string, direction: "up" | "down") => { - setSavedCategories((prev) => moveQuickCommandCategory(prev, categoryId, direction)); - }, []); + const handleMoveCategory = useCallback( + (categoryId: string, direction: "up" | "down") => { + setSavedCategories((prev) => + moveQuickCommandCategory(prev, categoryId, direction), + ); + }, + [], + ); const incrementUseCount = useCallback((id: string) => { setCommands((prev) => prev.map((c) => - c.id === id ? { ...c, use_count: (c.use_count ?? 0) + 1, updated_at: Date.now() } : c, + c.id === id + ? { ...c, use_count: (c.use_count ?? 0) + 1, updated_at: Date.now() } + : c, ), ); invoke("increment_quick_command_use_count", { id }).catch(() => {}); @@ -435,11 +496,15 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma skipNextSaveRef.current = true; setCommands((prev) => { const exists = prev.some((c) => c.id === cmd.id); - return exists ? prev.map((c) => (c.id === cmd.id ? cmd : c)) : [...prev, cmd]; + return exists + ? prev.map((c) => (c.id === cmd.id ? cmd : c)) + : [...prev, cmd]; }); if (newCategory) { setSavedCategories((prev) => - prev.find((c) => c.id === newCategory.id) ? prev : [...prev, newCategory], + prev.find((c) => c.id === newCategory.id) + ? prev + : [...prev, newCategory], ); } }); @@ -527,10 +592,7 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const handleExportQuickCommands = useCallback(async () => { try { if (runtime === "web") { - downloadJson("nyaterm-quick-commands.json", { - commands, - categories: savedCategories, - }); + downloadJson("nyaterm-quick-commands.json", { commands, categories: savedCategories }); toast.success(t("quickCommands.exportSuccess")); return; } @@ -579,23 +641,41 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma [allCategories, commands], ); const visibleCategoryRows = useMemo( - () => flattenVisibleQuickCommandCategoryTree(categoryTree, expandedCategoryIds), + () => + flattenVisibleQuickCommandCategoryTree(categoryTree, expandedCategoryIds), [categoryTree, expandedCategoryIds], ); - const uncategorizedCount = useMemo(() => getQuickCommandUncategorizedCount(commands), [commands]); + const uncategorizedCount = useMemo( + () => getQuickCommandUncategorizedCount(commands), + [commands], + ); - const viewMode = normalizeQuickCommandViewMode(appSettings.ui.quick_cmd_view_mode); - const sortMode = normalizeQuickCommandSortMode(appSettings.ui.quick_cmd_sort_mode); + const viewMode = normalizeQuickCommandViewMode( + appSettings.ui.quick_cmd_view_mode, + ); + const sortMode = normalizeQuickCommandSortMode( + appSettings.ui.quick_cmd_sort_mode, + ); const categorySidebarWidth = clampQuickCommandCategoryWidth( appSettings.ui.quick_cmd_category_width, ); - const storedSelectedCategory = appSettings.ui.quick_cmd_selected_category || "all"; + const storedSelectedCategory = + appSettings.ui.quick_cmd_selected_category || "all"; const selectedCategory = storedSelectedCategory === "all" || storedSelectedCategory === "uncategorized" || categoryById.has(storedSelectedCategory) ? storedSelectedCategory : "all"; + const newCommandCategoryId = + selectedCategory !== "all" && + selectedCategory !== "uncategorized" && + savedCategories.some((category) => category.id === selectedCategory) + ? selectedCategory + : null; + const contextCommand = contextCommandId + ? (commands.find((command) => command.id === contextCommandId) ?? null) + : null; const commandDragCategoryId = selectedCategory === "uncategorized" ? null @@ -623,7 +703,8 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma (delta: number) => { updateUi((current) => ({ quick_cmd_category_width: clampQuickCommandCategoryWidth( - (current.quick_cmd_category_width ?? QUICK_COMMAND_CATEGORY_WIDTH_DEFAULT) + delta, + (current.quick_cmd_category_width ?? + QUICK_COMMAND_CATEGORY_WIDTH_DEFAULT) + delta, ), })); }, @@ -658,8 +739,12 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma }, [categoryTree]); useEffect(() => { - if (selectedCategory === "all" || selectedCategory === "uncategorized") return; - const ancestorIds = collectQuickCommandCategoryAncestorIds(allCategories, selectedCategory); + if (selectedCategory === "all" || selectedCategory === "uncategorized") + return; + const ancestorIds = collectQuickCommandCategoryAncestorIds( + allCategories, + selectedCategory, + ); if (ancestorIds.length === 0) return; setExpandedCategoryIds((prev) => { const next = new Set(prev); @@ -701,19 +786,27 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma : `${commands.length}`; const categoryToDeleteCommandCount = useMemo(() => { if (!categoryToDelete) return 0; - const deleteIds = collectQuickCommandCategoryDescendantIds(allCategories, categoryToDelete.id); - return commands.filter((cmd) => !!cmd.category_id && deleteIds.has(cmd.category_id)).length; + const deleteIds = collectQuickCommandCategoryDescendantIds( + allCategories, + categoryToDelete.id, + ); + return commands.filter( + (cmd) => !!cmd.category_id && deleteIds.has(cmd.category_id), + ).length; }, [allCategories, categoryToDelete, commands]); const newCategoryParentLabel = useMemo(() => { const parentId = newCategoryDraft?.parentId; - return parentId ? buildQuickCommandCategoryPath(allCategories, parentId) || parentId : ""; + return parentId + ? buildQuickCommandCategoryPath(allCategories, parentId) || parentId + : ""; }, [allCategories, newCategoryDraft]); const headerControlClassName = "h-7 border-0 bg-[var(--df-bg-hover)] py-1 text-xs text-[var(--df-text)] shadow-none"; const getCommandCategoryName = useCallback( (cmd: QuickCommand) => cmd.category_id - ? buildQuickCommandCategoryPath(allCategories, cmd.category_id) || cmd.category_id + ? buildQuickCommandCategoryPath(allCategories, cmd.category_id) || + cmd.category_id : null, [allCategories], ); @@ -791,14 +884,17 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma }, [resetCategoryDrag, resolveCategoryDropPosition], ); - const handleCategoryRootDragOver = useCallback((event: DragEvent) => { - if (!categoryDragSourceRef.current) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - if (event.target === event.currentTarget) { - setCategoryDragTarget({ categoryId: null, position: "inside" }); - } - }, []); + const handleCategoryRootDragOver = useCallback( + (event: DragEvent) => { + if (!categoryDragSourceRef.current) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + if (event.target === event.currentTarget) { + setCategoryDragTarget({ categoryId: null, position: "inside" }); + } + }, + [], + ); const handleCategoryRootDrop = useCallback( (event: DragEvent) => { event.preventDefault(); @@ -836,10 +932,13 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma (event: DragEvent, targetCommandId: string) => { if (!canDragCommands) return; const sourceId = - commandDragSourceRef.current || event.dataTransfer.getData(QUICK_COMMAND_DRAG_MIME); + commandDragSourceRef.current || + event.dataTransfer.getData(QUICK_COMMAND_DRAG_MIME); if (!sourceId || sourceId === targetCommandId) return; const sourceCommand = commands.find((command) => command.id === sourceId); - const targetCommand = commands.find((command) => command.id === targetCommandId); + const targetCommand = commands.find( + (command) => command.id === targetCommandId, + ); if ( !sourceCommand || !targetCommand || @@ -859,7 +958,9 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const handleCommandDrop = useCallback( (event: DragEvent, targetCommandId: string) => { if (!canDragCommands || commandDragCategoryId === undefined) return; - const targetCommand = commands.find((command) => command.id === targetCommandId); + const targetCommand = commands.find( + (command) => command.id === targetCommandId, + ); if ( !targetCommand || (commandDragCategoryId @@ -870,11 +971,17 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma } event.preventDefault(); const sourceId = - commandDragSourceRef.current || event.dataTransfer.getData(QUICK_COMMAND_DRAG_MIME); + commandDragSourceRef.current || + event.dataTransfer.getData(QUICK_COMMAND_DRAG_MIME); resetCommandDrag(); if (!sourceId) return; setCommands((prev) => - reorderQuickCommandsWithinCategory(prev, sourceId, targetCommandId, commandDragCategoryId), + reorderQuickCommandsWithinCategory( + prev, + sourceId, + targetCommandId, + commandDragCategoryId, + ), ); }, [canDragCommands, commandDragCategoryId, commands, resetCommandDrag], @@ -882,7 +989,9 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const canDragCommand = useCallback( (cmd: QuickCommand) => canDragCommands && - (commandDragCategoryId ? cmd.category_id === commandDragCategoryId : !cmd.category_id), + (commandDragCategoryId + ? cmd.category_id === commandDragCategoryId + : !cmd.category_id), [canDragCommands, commandDragCategoryId], ); const renderCategoryContextMenuContent = useCallback( @@ -925,18 +1034,25 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma ), [openNewCategoryDialog, openNewCommandForCategory, t], ); - const renderCommandIcon = useCallback((cmd: QuickCommand, className = "text-[0.9rem]") => { - const dotColor = COLOR_DOT[cmd.color_tag || "default"] || COLOR_DOT.default; + const renderCommandIcon = useCallback( + (cmd: QuickCommand, className = "text-[0.9rem]") => { + const dotColor = + COLOR_DOT[cmd.color_tag || "default"] || COLOR_DOT.default; - if (cmd.icon_tag && QUICK_ICONS[cmd.icon_tag]) { - const iconDef = QUICK_ICONS[cmd.icon_tag]; - return ( - - ); - } + if (cmd.icon_tag && QUICK_ICONS[cmd.icon_tag]) { + const iconDef = QUICK_ICONS[cmd.icon_tag]; + return ( + + ); + } - return ; - }, []); + return ; + }, + [], + ); const renderExecutionBadge = useCallback( (cmd: QuickCommand, className?: string) => ( - {t("quickCommands.view")} + + {t("quickCommands.view")} + - openQuickCommand(JSON.stringify(cmd))}> + openQuickCommand(JSON.stringify(cmd))} + > {t("quickCommands.edit")} {onSendToAll && ( - handleSendToAll(cmd)}> + handleSendToAll(cmd)} + > {t("quickCommands.sendToAll")} )} - setCommandToDelete(cmd)}> + setCommandToDelete(cmd)} + > {t("quickCommands.delete")} @@ -1123,6 +1249,13 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma {t("quickCommands.edit")} + void handleCopyCommand(cmd.command)} + > + + {t("quickCommands.copyCommand")} + {onSendToAll && ( ), - [handleSendToAll, onSendToAll, sendDisabled, t], + [handleCopyCommand, handleSendToAll, onSendToAll, sendDisabled, t], ); const renderCommandListItem = useCallback( (cmd: QuickCommand) => { @@ -1150,46 +1283,47 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const isDragging = draggingCommandId === cmd.id; const isDropTarget = commandDragTarget?.commandId === cmd.id; return ( - - -
handleCommandDragStart(event, cmd.id)} - onDragOver={(event) => handleCommandDragOver(event, cmd.id)} - onDrop={(event) => handleCommandDrop(event, cmd.id)} - onDragEnd={resetCommandDrag} - className={cn( - "group flex min-h-11 w-full min-w-0 items-center gap-2 rounded-md border border-border/35 bg-muted/15 px-2 py-1.5 text-xs transition-colors hover:bg-muted/45 hover:text-foreground", - draggable && "cursor-grab active:cursor-grabbing", - isDragging && "opacity-50", - isDropTarget && "ring-1 ring-primary/70", - )} - style={{ color: "var(--df-text)" }} - > - - {renderCommandActions(cmd)} -
-
- {renderContextMenuContent(cmd)} -
+
+ + {cmd.command} + +
+ + {renderCommandActions(cmd)} + ); }, [ @@ -1203,7 +1337,6 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma resetCommandDrag, renderCommandActions, renderCommandIcon, - renderContextMenuContent, sendDisabled, ], ); @@ -1213,42 +1346,43 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const isDragging = draggingCommandId === cmd.id; const isDropTarget = commandDragTarget?.commandId === cmd.id; return ( - - -
handleCommandDragStart(event, cmd.id)} - onDragOver={(event) => handleCommandDragOver(event, cmd.id)} - onDrop={(event) => handleCommandDrop(event, cmd.id)} - onDragEnd={resetCommandDrag} - className={cn( - "group flex h-8 w-full min-w-0 items-center gap-1.5 rounded px-1.5 text-xs transition-colors hover:bg-muted/45 hover:text-foreground", - draggable && "cursor-grab active:cursor-grabbing", - isDragging && "opacity-50", - isDropTarget && "ring-1 ring-primary/70", - )} - style={{ color: "var(--df-text)" }} - > - - {renderCommandActions(cmd, { showBadge: false })} -
-
- {renderContextMenuContent(cmd)} -
+
handleCommandDragStart(event, cmd.id)} + onDragOver={(event) => handleCommandDragOver(event, cmd.id)} + onDrop={(event) => handleCommandDrop(event, cmd.id)} + onDragEnd={resetCommandDrag} + className={cn( + "group flex h-8 w-full min-w-0 items-center gap-1.5 rounded px-1.5 text-xs transition-colors hover:bg-muted/45 hover:text-foreground", + draggable && "cursor-grab active:cursor-grabbing", + isDragging && "opacity-50", + isDropTarget && "ring-1 ring-primary/70", + )} + style={{ color: "var(--df-text)" }} + > + + {renderCommandActions(cmd, { showBadge: false })} +
); }, [ @@ -1262,7 +1396,6 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma resetCommandDrag, renderCommandActions, renderCommandIcon, - renderContextMenuContent, sendDisabled, ], ); @@ -1274,83 +1407,83 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma const isDropTarget = commandDragTarget?.commandId === cmd.id; return ( - - - - - - - - + + + + +
+
+
+ + {renderCommandIcon(cmd, "text-[0.875rem]")} + + + {cmd.label} + +
+ {categoryName && ( + + {categoryName} - - {cmd.label} - -
- {categoryName && ( - - {categoryName} - - )} -
-
- {cmd.execution_mode === "append" ? ( - - ) : ( - - )} - {cmd.execution_mode === "append" - ? t("quickCommands.appendOnly") - : t("quickCommands.executeImmediately")} -
-
- -
- {cmd.description && ( -
- {cmd.description} -
)} - - {renderCommandPreview(cmd)} +
+
+ {cmd.execution_mode === "append" ? ( + + ) : ( + + )} + {cmd.execution_mode === "append" + ? t("quickCommands.appendOnly") + : t("quickCommands.executeImmediately")}
- - - {renderContextMenuContent(cmd)} - + +
+ {cmd.description && ( +
+ {cmd.description} +
+ )} + + {renderCommandPreview(cmd)} +
+
+ + ); }, [ @@ -1365,7 +1498,6 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma resetCommandDrag, renderCommandIcon, renderCommandPreview, - renderContextMenuContent, sendDisabled, t, ], @@ -1380,7 +1512,10 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma title={t("panel.quickCommands")} meta={ commands.length > 0 ? ( - + {headerMetaText} ) : null @@ -1407,7 +1542,10 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma
- + @@ -1419,7 +1557,9 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma className="h-6 w-6 shrink-0 rounded-md p-0 transition-colors hover:bg-[var(--df-bg-hover)]" style={{ color: - sortMode !== "created" ? "var(--df-primary)" : "var(--df-text-muted)", + sortMode !== "created" + ? "var(--df-primary)" + : "var(--df-text-muted)", }} aria-label={t("quickCommands.sort")} > @@ -1427,12 +1567,16 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma - {t("quickCommands.sort")} + + {t("quickCommands.sort")} + setSortMode(normalizeQuickCommandSortMode(value))} + onValueChange={(value) => + setSortMode(normalizeQuickCommandSortMode(value)) + } > {t("quickCommands.sortByCreated")} @@ -1471,12 +1615,16 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma - {t("quickCommands.viewMode")} + + {t("quickCommands.viewMode")} +
setViewMode(normalizeQuickCommandViewMode(value))} + onValueChange={(value) => + setViewMode(normalizeQuickCommandViewMode(value)) + } > @@ -1494,7 +1642,10 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma - +
@@ -1505,12 +1656,16 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma className="h-6 w-6 shrink-0 rounded-md p-0 transition-colors hover:bg-[var(--df-bg-hover)]" style={{ color: "var(--df-text-muted)" }} aria-label={t("quickCommands.addCommand")} - onClick={() => openQuickCommand()} + onClick={() => + openNewCommandForCategory(newCommandCategoryId) + } > - {t("quickCommands.addCommand")} + + {t("quickCommands.addCommand")} + @@ -1526,7 +1681,9 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma - {t("quickCommands.export")} + + {t("quickCommands.export")} + @@ -1542,11 +1699,16 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma - {t("quickCommands.import")} + + {t("quickCommands.import")} +
- + @@ -1563,11 +1725,15 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma - {t("ai.generateCommand")} + + {t("ai.generateCommand")} +
-
{t("ai.generateCommand")}
+
+ {t("ai.generateCommand")} +
-
-
- {filteredCommands.length === 0 ? ( -
- - {t("quickCommands.noCommandsFound")} - + + +
{ + const target = + event.target instanceof Element ? event.target : null; + const item = target?.closest( + "[data-quick-command-id]", + ); + setContextCommandId(item?.dataset.quickCommandId ?? null); + }} + > +
+ {filteredCommands.length === 0 ? ( +
+ + + {t("quickCommands.noCommandsFound")} + + +
+ ) : ( + filteredCommands.map((cmd) => + viewMode === "tile" + ? renderCommandTile(cmd) + : viewMode === "compact" + ? renderCommandCompactItem(cmd) + : renderCommandListItem(cmd), + ) + )}
- ) : ( - filteredCommands.map((cmd) => - viewMode === "tile" - ? renderCommandTile(cmd) - : viewMode === "compact" - ? renderCommandCompactItem(cmd) - : renderCommandListItem(cmd), - ) - )} -
-
+
+ + {contextCommand ? ( + renderContextMenuContent(contextCommand) + ) : ( + + + openNewCommandForCategory(newCommandCategoryId) + } + > + + {t("quickCommands.addCommand")} + + + )} +
{promptCmd && ( ({ + isWindows: true, + updateAppSettings: vi.fn(), + updateUi: vi.fn(), +})); + +vi.mock("@/lib/platform", () => ({ + get isWindows() { + return state.isWindows; + }, +})); + +vi.mock("@/context/AppContext", () => ({ + useApp: () => ({ + appSettings: { + general: { + startup_restore: true, + startup_restore_window_layout: true, + minimize_to_tray: false, + boss_key: null, + confirm_on_close: true, + rdp_client_mode: "builtin", + }, + ui: { + language: "en", + header_status_visible: true, + header_status_mode: "session", + }, + diagnostics: { level: "info", retention_days: 7 }, + }, + updateAppSettings: state.updateAppSettings, + updateUi: state.updateUi, + }), +})); + +vi.mock("@/hooks/useConfigTransfer", () => ({ + useConfigTransfer: () => ({ + handleExportDiagnostics: vi.fn(), + handleOpenLogs: vi.fn(), + }), +})); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + i18n: { changeLanguage: vi.fn() }, + t: (key: string) => key, + }), +})); + +describe("GeneralTab RDP client setting", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.isWindows = true; + }); + + it("shows the RDP client selector on Windows and saves Windows Remote Desktop", () => { + render(); + const field = + screen.getByText("settings.rdpClient").parentElement?.parentElement; + expect(field).not.toBeNull(); + const combo = within(field as HTMLElement).getByRole("combobox"); + + fireEvent.keyDown(combo, { key: "ArrowDown" }); + fireEvent.click( + screen.getByRole("option", { name: "settings.rdpClientWindows" }), + ); + + expect(state.updateAppSettings).toHaveBeenCalledWith({ + general: { + startup_restore: true, + startup_restore_window_layout: true, + minimize_to_tray: false, + boss_key: null, + confirm_on_close: true, + rdp_client_mode: "windows", + }, + }); + }); + + it("does not show the Windows-only RDP client selector off Windows", () => { + state.isWindows = false; + render(); + expect(screen.queryByText("settings.rdpClient")).toBeNull(); + }); +}); diff --git a/src/components/settings/GeneralTab.tsx b/src/components/settings/GeneralTab.tsx index 5065ada2..3cf643b9 100644 --- a/src/components/settings/GeneralTab.tsx +++ b/src/components/settings/GeneralTab.tsx @@ -6,6 +6,7 @@ import { useConfigTransfer } from "@/hooks/useConfigTransfer"; import { AVAILABLE_LANGUAGES } from "@/i18n"; import { runtime, supports } from "@/lib/backend/runtime"; import { HEADER_STATUS_MODES, normalizeHeaderStatusMode } from "@/lib/headerStatus"; +import { isWindows } from "@/lib/platform"; import { SettingFieldGrid, SettingRow, @@ -70,6 +71,29 @@ export function GeneralTab() { + {isWindows && ( + + updateAppSettings({ + general: { + ...appSettings.general, + rdp_client_mode: value === "windows" ? "windows" : "builtin", + }, + }) + } + > + + {t("settings.rdpClientBuiltin")} + + + {t("settings.rdpClientWindows")} + + + )} + ({ invoke: vi.fn(), closeStaleCreatedSession: vi.fn(), focusTerminalSession: vi.fn(), + launchSavedRdpWithSystemClient: vi.fn(), openNewSession: vi.fn(), })); vi.mock("@/lib/invoke", () => ({ invoke: mocks.invoke })); @@ -17,6 +18,7 @@ vi.mock("@/lib/appSessionFactory", async (importOriginal) => ({ ...(await importOriginal()), closeStaleCreatedSession: mocks.closeStaleCreatedSession, focusTerminalSession: mocks.focusTerminalSession, + launchSavedRdpWithSystemClient: mocks.launchSavedRdpWithSystemClient, })); function deferred() { @@ -45,6 +47,7 @@ function renderConnect(overrides: Partial setActiveTabId: vi.fn(), updatePaneSession: vi.fn(), updateTabSession: vi.fn(), + rdpClientMode: "builtin", setTerminalWindows: vi.fn(), updateAutoIconForSessionStart: vi.fn(), ...overrides, @@ -62,6 +65,7 @@ beforeEach(() => { command === "get_saved_connections" ? [connection] : "new-session", ); mocks.closeStaleCreatedSession.mockResolvedValue(undefined); + mocks.launchSavedRdpWithSystemClient.mockResolvedValue(false); }); describe("useConnectAfterEdit", () => { @@ -183,6 +187,31 @@ describe("useConnectAfterEdit", () => { }); }); + it("launches a system RDP connection before creating a pending pane", async () => { + const rdpConnection = { ...connection, type: "rdp" as const }; + mocks.invoke.mockResolvedValueOnce([rdpConnection]); + mocks.launchSavedRdpWithSystemClient.mockResolvedValueOnce(true); + const { result, options } = renderConnect({ rdpClientMode: "windows" }); + + await act(() => result.current({ connectionId: "conn-1" })); + + expect(mocks.launchSavedRdpWithSystemClient).toHaveBeenCalledWith( + rdpConnection, + "windows", + ); + expect(options.addPendingTab).not.toHaveBeenCalled(); + expect(options.markPaneConnecting).not.toHaveBeenCalled(); + expect(options.updatePaneSession).not.toHaveBeenCalled(); + expect(options.updateTabSession).not.toHaveBeenCalled(); + expect(options.updateAutoIconForSessionStart).not.toHaveBeenCalled(); + expect(options.recordRecentConnection).toHaveBeenCalledWith("conn-1"); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "create_rdp_session", + expect.anything(), + ); + }); + it("preserves the ID fallback when the saved connection is missing", async () => { mocks.invoke.mockResolvedValueOnce([]); const { result, options } = renderConnect(); diff --git a/src/hooks/useConnectAfterEdit.ts b/src/hooks/useConnectAfterEdit.ts index b6fe857a..667bf93e 100644 --- a/src/hooks/useConnectAfterEdit.ts +++ b/src/hooks/useConnectAfterEdit.ts @@ -1,4 +1,6 @@ import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import type { AppContextType } from "@/context/AppContext"; import { closeStaleCreatedSession, @@ -6,13 +8,14 @@ import { getConnectionSessionType, getRemoteDesktopPaneDisplay, isSessionCreationCancelled, + launchSavedRdpWithSystemClient, } from "@/lib/appSessionFactory"; import { getErrorMessage, shouldPromptConnectionEditOnFailure } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { insertTabIntoLeaf, type TerminalWindowNode } from "@/lib/tabWindows"; import { openNewSession } from "@/lib/windowManager"; import { findSessionPaneById, getActivePane } from "@/lib/workspaceTabs"; -import type { SavedConnection } from "@/types/global"; +import type { GeneralSettings, SavedConnection } from "@/types/global"; export interface SessionConnectAfterEditPayload { connectionId: string; @@ -41,6 +44,7 @@ interface ConnectAfterEditOptions > { setTerminalWindows: Dispatch>; updateAutoIconForSessionStart: (connectionId: string, sessionId: string) => void; + rdpClientMode: GeneralSettings["rdp_client_mode"]; } export function useConnectAfterEdit({ @@ -58,7 +62,9 @@ export function useConnectAfterEdit({ updateTabSession, setTerminalWindows, updateAutoIconForSessionStart, + rdpClientMode, }: ConnectAfterEditOptions) { + const { t } = useTranslation(); const tabsRef = useRef(tabs); useEffect(() => { tabsRef.current = tabs; @@ -71,6 +77,20 @@ export function useConnectAfterEdit({ const conns = await invoke("get_saved_connections"); const conn = conns.find((c) => c.id === connectionId); const connName = conn?.name ?? connectionId; + if (conn) { + try { + if (await launchSavedRdpWithSystemClient(conn, rdpClientMode)) { + recordRecentConnection(connectionId); + return; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + toast.error( + t("savedConnections.connectionFailed", { error: errorMessage }), + ); + return; + } + } const sessionType = getConnectionSessionType(conn); const sourceTab = sourceTabId ? (tabsRef.current.find((item) => item.id === sourceTabId) ?? null) @@ -200,12 +220,14 @@ export function useConnectAfterEdit({ markPaneConnectionFailed, markTabConnectionFailed, recordRecentConnection, + rdpClientMode, setActivePane, setActiveTabId, updatePaneSession, updateTabSession, setTerminalWindows, updateAutoIconForSessionStart, + t, ], ); } diff --git a/src/hooks/useTerminalWindowLayout.test.tsx b/src/hooks/useTerminalWindowLayout.test.tsx index 4fb29152..0fd6bd09 100644 --- a/src/hooks/useTerminalWindowLayout.test.tsx +++ b/src/hooks/useTerminalWindowLayout.test.tsx @@ -22,6 +22,7 @@ function renderLayout(tabs: Tab[], savedLayout: RestorableTerminalWindowNode | n minimize_to_tray: false, boss_key: null, confirm_on_close: true, + rdp_client_mode: "builtin", }, savedLayout, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 44b4b16c..9a4c6a6a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2506,6 +2506,10 @@ "rdpCertificateVerifyNew": "New RDP certificate. Verify the fingerprint below.", "rdpCertificateVerifyTitle": "RDP Certificate Verification", "rdpCertificateVerifyWarning": "WARNING: The RDP certificate changed. Accept only if you trust this server.", + "rdpClient": "RDP client", + "rdpClientBuiltin": "NyaTerm built-in RDP", + "rdpClientDesc": "Choose how saved RDP connections open on Windows.", + "rdpClientWindows": "Windows Remote Desktop", "reconnectRestoreCwd": "Restore last working directory on reconnect", "reconnectRestoreCwdDesc": "When enabled, an SSH session reconnects to the last known working directory. Requires shell integration (bash/zsh/fish); falls back to the default behavior when no directory was recorded.", "recordingAutoStart": "Auto-record new sessions", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index b01b200f..6afc3bf6 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2504,6 +2504,10 @@ "rdpCertificateVerifyNew": "새 RDP 인증서입니다. 아래 지문을 확인하세요.", "rdpCertificateVerifyTitle": "RDP 인증서 검증", "rdpCertificateVerifyWarning": "경고: RDP 인증서가 변경되었습니다. 이 서버를 신뢰할 때만 수락하세요.", + "rdpClient": "RDP 클라이언트", + "rdpClientBuiltin": "NyaTerm 내장 RDP", + "rdpClientDesc": "Windows에서 저장된 RDP 연결을 여는 방식을 선택합니다.", + "rdpClientWindows": "Windows 원격 데스크톱", "reconnectRestoreCwd": "재연결 시 마지막 작업 디렉터리 복원", "reconnectRestoreCwdDesc": "활성화하면 SSH 세션 재연결 시 연결이 끊어지기 전 작업 디렉터리로 cd 합니다. 셸 통합(bash/zsh/fish)이 필요하며 기록이 없으면 기존 동작을 유지합니다.", "recordingAutoStart": "새 세션 자동 녹화", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 7da63b2f..05273e50 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2504,6 +2504,10 @@ "rdpCertificateVerifyNew": "新的 RDP 证书,请验证以下指纹。", "rdpCertificateVerifyTitle": "RDP 证书验证", "rdpCertificateVerifyWarning": "警告:RDP 证书已更改。请仅在你信任此服务器时接受。", + "rdpClient": "RDP 客户端", + "rdpClientBuiltin": "NyaTerm 内置 RDP", + "rdpClientDesc": "选择在 Windows 上打开已保存 RDP 连接的方式。", + "rdpClientWindows": "Windows 远程桌面", "reconnectRestoreCwd": "重连后恢复上次工作目录", "reconnectRestoreCwdDesc": "启用后,SSH 会话重连时自动 cd 回断开前的工作目录。需要 shell 集成(bash/zsh/fish)支持,无记录时保持原有行为。", "recordingAutoStart": "自动录制新会话", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 010d2c08..8cd2e4df 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2504,6 +2504,10 @@ "rdpCertificateVerifyNew": "新的 RDP 憑證,請驗證以下指紋。", "rdpCertificateVerifyTitle": "RDP 憑證驗證", "rdpCertificateVerifyWarning": "警告:RDP 憑證已更改。請僅在你信任此伺服器時接受。", + "rdpClient": "RDP 用戶端", + "rdpClientBuiltin": "NyaTerm 內建 RDP", + "rdpClientDesc": "選擇在 Windows 上開啟已儲存 RDP 連線的方式。", + "rdpClientWindows": "Windows 遠端桌面", "reconnectRestoreCwd": "重連後恢復上次工作目錄", "reconnectRestoreCwdDesc": "啟用後,SSH 工作階段重連時自動 cd 回中斷前的工作目錄。需要 shell 整合(bash/zsh/fish)支援,無記錄時保持原有行為。", "recordingAutoStart": "自動錄製新工作階段", diff --git a/src/lib/appSessionFactory.test.ts b/src/lib/appSessionFactory.test.ts index 8c7322ec..1c6b16c6 100644 --- a/src/lib/appSessionFactory.test.ts +++ b/src/lib/appSessionFactory.test.ts @@ -1,7 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { setOwnerMainWindowLabel } from "./windowManager"; import type { SavedConnection, TerminalSessionPane } from "@/types/global"; -import { createTemporarySession, createSessionForConnection, createSessionForPane } from "./appSessionFactory"; +import { + createTemporarySession, + createSessionForConnection, + createSessionForPane, + launchSavedRdpWithSystemClient, + shouldLaunchSavedRdpWithSystemClient, +} from "./appSessionFactory"; const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); @@ -105,3 +111,50 @@ it("retains the temporary Telnet route and encoding when connecting and reconnec expect(invokeMock).toHaveBeenCalledWith("create_telnet_session", expect.objectContaining({ network: temporary.network, encoding: "GBK", createRequestId: "temporary-create" })); expect(invokeMock).toHaveBeenCalledWith("create_telnet_session", expect.objectContaining({ network: temporary.network, encoding: "GBK", createRequestId: "temporary-reconnect", recordingScopeId: "pane-telnet" })); }); + +describe("Windows system RDP routing", () => { + beforeEach(() => { + invokeMock.mockReset().mockResolvedValue(undefined); + }); + + it("launches a saved RDP connection externally only on Windows system mode", async () => { + const connection = { id: "rdp-1", type: "rdp" } as SavedConnection; + + expect( + shouldLaunchSavedRdpWithSystemClient(connection, "windows", true), + ).toBe(true); + expect( + await launchSavedRdpWithSystemClient(connection, "windows", true), + ).toBe(true); + expect(invokeMock).toHaveBeenCalledExactlyOnceWith("launch_windows_rdp", { + connectionId: "rdp-1", + }); + }); + + it("keeps built-in and non-Windows RDP on the existing session path", async () => { + const connection = { id: "rdp-1", type: "rdp" } as SavedConnection; + + expect( + shouldLaunchSavedRdpWithSystemClient(connection, "builtin", true), + ).toBe(false); + expect( + shouldLaunchSavedRdpWithSystemClient(connection, "windows", false), + ).toBe(false); + expect( + await launchSavedRdpWithSystemClient(connection, "builtin", true), + ).toBe(false); + expect( + await launchSavedRdpWithSystemClient(connection, "windows", false), + ).toBe(false); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("does not route non-RDP saved connections to Windows Remote Desktop", async () => { + const connection = { id: "ssh-1", type: "ssh" } as SavedConnection; + + expect( + await launchSavedRdpWithSystemClient(connection, "windows", true), + ).toBe(false); + expect(invokeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/appSessionFactory.ts b/src/lib/appSessionFactory.ts index 2d266ca7..ba41d641 100644 --- a/src/lib/appSessionFactory.ts +++ b/src/lib/appSessionFactory.ts @@ -5,6 +5,7 @@ import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { getOwnerMainWindowLabel } from "@/lib/windowManager"; import { logger } from "@/lib/logger"; +import { isWindows } from "@/lib/platform"; import { buildTerminalCommandInput, clearSessionCommandHistory, @@ -13,6 +14,7 @@ import { import type { TemporaryLinkConfig } from "@/lib/temporaryLink"; import { captureTerminalReconnectContent } from "@/lib/terminalReconnectHistory"; import type { + GeneralSettings, SavedConnection, SessionPane, SessionType, @@ -63,6 +65,26 @@ export function getRemoteDesktopPaneDisplay( return undefined; } +export function shouldLaunchSavedRdpWithSystemClient( + connection: Pick | null | undefined, + mode: GeneralSettings["rdp_client_mode"] | undefined, + windows = isWindows, +) { + return windows && mode === "windows" && connection?.type === "rdp"; +} + +export async function launchSavedRdpWithSystemClient( + connection: Pick, + mode: GeneralSettings["rdp_client_mode"] | undefined, + windows = isWindows, +) { + if (!shouldLaunchSavedRdpWithSystemClient(connection, mode, windows)) { + return false; + } + await invoke("launch_windows_rdp", { connectionId: connection.id }); + return true; +} + export function isSessionCreationCancelled(error: unknown) { return getErrorMessage(error) .toLowerCase() diff --git a/src/types/global.d.ts b/src/types/global.d.ts index a61b0f63..b486820c 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1159,6 +1159,7 @@ export interface GeneralSettings { minimize_to_tray: boolean; boss_key: string | null; confirm_on_close: boolean; + rdp_client_mode: "builtin" | "windows"; } export type BackgroundImageFit = "cover" | "contain" | "stretch" | "tile";