diff --git a/src-tauri/src/config/settings/terminal.rs b/src-tauri/src/config/settings/terminal.rs index 686aa44e..a800db68 100644 --- a/src-tauri/src/config/settings/terminal.rs +++ b/src-tauri/src/config/settings/terminal.rs @@ -90,6 +90,8 @@ pub struct TerminalSettings { pub show_multi_line_paste_dialog: bool, #[serde(default = "default_true")] pub paste_image_as_path: bool, + #[serde(default = "default_true")] + pub reconnect_restore_cwd: bool, } fn default_scrollback() -> u32 { @@ -156,6 +158,7 @@ impl Default for TerminalSettings { timestamp_format: default_timestamp_format(), show_multi_line_paste_dialog: true, paste_image_as_path: true, + reconnect_restore_cwd: true, } } } @@ -176,6 +179,18 @@ mod tests { assert_eq!(settings.keep_alive_interval, 60); } + #[test] + fn missing_reconnect_restore_cwd_defaults_to_true() { + let settings: TerminalSettings = serde_json::from_value(serde_json::json!({ + "scrollback_lines": 5000, + "keep_alive_interval": 60 + })) + .expect("legacy terminal settings deserialize"); + + assert!(settings.reconnect_restore_cwd); + assert!(TerminalSettings::default().reconnect_restore_cwd); + } + #[test] fn missing_timestamp_format_defaults_to_seconds() { let settings: TerminalSettings = diff --git a/src-tauri/src/core/ssh/bash_persistent.sh b/src-tauri/src/core/ssh/bash_persistent.sh index ed15f394..2edde819 100644 --- a/src-tauri/src/core/ssh/bash_persistent.sh +++ b/src-tauri/src/core/ssh/bash_persistent.sh @@ -53,7 +53,8 @@ __nyaterm_prompt(){ unset NYATERM_READY_PENDING printf '%s' "${NYATERM_READY_MARKER-}" fi - printf '\033]7;file://%s%s\007' "$(__nyaterm_host)" "$PWD" + local cwd="${PWD//%/%25}" + printf '\033]7;file://%s%s\007' "$(__nyaterm_host)" "$cwd" return "$status" } diff --git a/src-tauri/src/core/ssh/io.rs b/src-tauri/src/core/ssh/io.rs index 8e311f86..44b3c039 100644 --- a/src-tauri/src/core/ssh/io.rs +++ b/src-tauri/src/core/ssh/io.rs @@ -594,7 +594,20 @@ struct PendingStartupCommand { delay_ms: u64, } +/// Build renderer-supplied startup input without allowing terminal controls. pub(super) fn build_startup_command_input(command: &str) -> Option> { + if command.trim().is_empty() || contains_terminal_control(command) { + return None; + } + + let mut input = command.as_bytes().to_vec(); + if !input.ends_with(b"\r") { + input.push(b'\r'); + } + Some(input) +} + +fn build_post_login_command_input(command: &str) -> Option> { if command.trim().is_empty() { return None; } @@ -607,6 +620,12 @@ pub(super) fn build_startup_command_input(command: &str) -> Option> { Some(input) } +fn contains_terminal_control(value: &str) -> bool { + value + .chars() + .any(|ch| matches!(ch, '\u{0000}'..='\u{001F}' | '\u{007F}'..='\u{009F}')) +} + fn arm_post_login_timer( phase: &IoPhase, pending_post_login: &Option, @@ -911,7 +930,7 @@ pub(super) async fn ssh_io_loop( let mut initial_remote_data_logged = false; let mut injection_sent_at: Option = None; let mut pending_post_login = post_login.and_then(|config| { - build_startup_command_input(&config.command).map(|input| PendingStartupCommand { + build_post_login_command_input(&config.command).map(|input| PendingStartupCommand { input, delay_ms: config.delay_ms, }) @@ -1408,9 +1427,9 @@ mod tests { use super::{ INITIAL_INJECT_DELAY_MS, INJECT_TIMEOUT_SECS, InjectionEvent, InjectionTimeoutEvent, IoPhase, PendingStartupCommand, SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES, - append_suppressed_visible_and_take_passthrough, build_startup_command_input, - discard_suppressed_output, handle_injection_result, handle_injection_timeout, - open_shell_channel, should_send_initial_injection, + append_suppressed_visible_and_take_passthrough, build_post_login_command_input, + build_startup_command_input, discard_suppressed_output, handle_injection_result, + handle_injection_timeout, open_shell_channel, should_send_initial_injection, }; use crate::config::SftpCwdFollowMode; use crate::core::ssh::osc::{OscResult, OscStripper, build_ready_marker}; @@ -1551,21 +1570,46 @@ mod tests { #[test] fn post_login_input_normalizes_line_endings_and_adds_enter() { - let input = build_startup_command_input("cd /opt/app\nclear").expect("input"); + let input = build_post_login_command_input("cd /opt/app\nclear").expect("input"); assert_eq!(input, b"cd /opt/app\rclear\r"); } #[test] fn post_login_input_preserves_existing_trailing_enter() { - let input = build_startup_command_input("uptime\r").expect("input"); + let input = build_post_login_command_input("uptime\r").expect("input"); assert_eq!(input, b"uptime\r"); } #[test] fn post_login_input_ignores_blank_commands() { - assert!(build_startup_command_input(" \n\t ").is_none()); + assert!(build_post_login_command_input(" \n\t ").is_none()); + } + + #[test] + fn startup_input_rejects_c0_del_and_c1_controls() { + for code in 0x00..=0x1f { + let command = format!("cd /tmp/a{}b", char::from_u32(code).expect("C0 code point")); + assert!( + build_startup_command_input(&command).is_none(), + "C0 control U+{code:04X} must be rejected" + ); + } + for code in 0x7f..=0x9f { + let command = format!("cd /tmp/a{}b", char::from_u32(code).expect("C1 code point")); + assert!( + build_startup_command_input(&command).is_none(), + "DEL/C1 control U+{code:04X} must be rejected" + ); + } + } + + #[test] + fn startup_input_adds_enter_to_a_safe_command() { + let input = build_startup_command_input("cd '/opt/app'").expect("input"); + + assert_eq!(input, b"cd '/opt/app'\r"); } fn osc_result(ready: bool, visible_after_ready: &str) -> OscResult { diff --git a/src-tauri/src/core/ssh/osc.rs b/src-tauri/src/core/ssh/osc.rs index b1df942d..aad1b4b5 100644 --- a/src-tauri/src/core/ssh/osc.rs +++ b/src-tauri/src/core/ssh/osc.rs @@ -146,7 +146,8 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " }};\n", " __nyaterm_prompt(){{\n", " local status=$?; __nyaterm_prune_history; __nyaterm_emit_command;\n", - " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\";\n", + " local cwd=\"${{PWD//%/%25}}\";\n", + " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$cwd\";\n", " return \"$status\";\n", " }};\n", " __nyaterm_prompt_state_writable(){{\n", @@ -226,7 +227,7 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " __nyaterm_host(){{ hostname 2>/dev/null || printf localhost; }};", " __nyaterm_ready_failed(){{ [ -n \"${{__nyaterm_failure_reported:-}}\" ] || {{ __nyaterm_failure_reported=1; printf '%s' \"${{NYATERM_READY_FAILED_MARKER-}}\"; }}; }};", " __nyaterm_emit(){{", - " local saved_status=$?; printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\"; return \"$saved_status\";", + " local saved_status=$?; local cwd=\"${{PWD//%/%25}}\"; printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$cwd\"; return \"$saved_status\";", " }};", " __nyaterm_preexec(){{", " local saved_status=$?; if [ -n \"$1\" ]; then", @@ -261,7 +262,7 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " set -g NYATERM_COMMAND_MARKER \"{}\";", " set -g NYATERM_READY_FAILED_MARKER (printf '{}');", " function __nyaterm_emit --on-event fish_prompt;", - " set -l saved_status $status; printf '\\033]7;file://%s%s\\007' (hostname) $PWD; return $saved_status;", + " set -l saved_status $status; set -l cwd (string replace -a '%' '%25' -- $PWD); printf '\\033]7;file://%s%s\\007' (hostname) $cwd; return $saved_status;", " end;", " function __nyaterm_preexec --on-event fish_preexec;", " set -l saved_status $status; if test -n \"$argv[1]\";", @@ -405,7 +406,8 @@ const ZSH_PERSISTENT_SCRIPT: &str = concat!( "__nyaterm_emit(){\n", " local saved_status=$?\n", " if [ -n \"${NYATERM_READY_PENDING:-}\" ]; then unset NYATERM_READY_PENDING; printf '%s' \"${NYATERM_READY_MARKER-}\"; fi\n", - " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\"\n", + " local cwd=\"${PWD//%/%25}\"\n", + " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$cwd\"\n", " return \"$saved_status\"\n", "}\n", "__nyaterm_preexec(){\n", @@ -439,7 +441,8 @@ const FISH_PERSISTENT_SCRIPT: &str = concat!( " set -e NYATERM_READY_PENDING\n", " printf '%s' \"$NYATERM_READY_MARKER\"\n", " end\n", - " printf '\\033]7;file://%s%s\\007' (hostname) $PWD\n", + " set -l cwd (string replace -a '%' '%25' -- $PWD)\n", + " printf '\\033]7;file://%s%s\\007' (hostname) $cwd\n", " return $saved_status\n", "end\n", "function __nyaterm_preexec\n", @@ -1350,6 +1353,24 @@ eval "$PROMPT_COMMAND" 2>/dev/null || true ); assert!(fish.contains("printf '\\033]7;file://%s%s\\007'")); assert_no_empty_tail_printf(&fish); + assert!(bash.contains("${PWD//%/%25}")); + assert!(zsh.contains("${PWD//%/%25}")); + assert!(fish.contains("string replace -a '%' '%25' -- $PWD")); + assert!( + persistent_script(ShellKind::Bash) + .expect("bash persistent script") + .contains("${PWD//%/%25}") + ); + assert!( + persistent_script(ShellKind::Zsh) + .expect("zsh persistent script") + .contains("${PWD//%/%25}") + ); + assert!( + persistent_script(ShellKind::Fish) + .expect("fish persistent script") + .contains("string replace -a '%' '%25' -- $PWD") + ); assert!(bash.contains("NYATERM_COMMAND_MARKER")); assert!(zsh.contains("NYATERM_COMMAND_MARKER")); diff --git a/src/App.tsx b/src/App.tsx index c2c059fa..e2bd74c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,10 @@ import { type StartupCommandRequest, sendStartupCommandToSession, } from "./lib/appSessionFactory"; +import { + buildReconnectCwdStartupCommand, + carryOverSessionCwd, +} from "./lib/terminalSessionCwd"; import { buildPanelOpenUpdate, canCreateSessionFromPane, @@ -2455,6 +2459,20 @@ function App() { toast.info(t("tabCtx.fileSessionInUse")); }, [t]); + const buildPaneReconnectCwdStartupCommand = useCallback( + (pane: Pick) => + appSettings.terminal.reconnect_restore_cwd ?? true + ? buildReconnectCwdStartupCommand( + pane.sessionId, + appSettings.interaction.duplicate_session_command_delay_ms, + ) + : undefined, + [ + appSettings.terminal.reconnect_restore_cwd, + appSettings.interaction.duplicate_session_command_delay_ms, + ], + ); + const handleReconnectSession = useCallback( async (tab: Tab) => { const pane = getActivePane(tab); @@ -2473,12 +2491,18 @@ function App() { }).catch(() => {}); } const reconnectContent = capturePaneReconnectContent(pane); + const reconnectCwdStartupCommand = buildPaneReconnectCwdStartupCommand(pane); const closed = await closePaneBackendSession(pane); if (!closed) { throw new Error("close_session_failed"); } - const newSessionId = await createSessionForPane(pane); + const newSessionId = await createSessionForPane( + pane, + undefined, + reconnectCwdStartupCommand, + ); + carryOverSessionCwd(pane.sessionId, newSessionId); if (!hasPane(tab.id, pane.id)) { await closeStaleCreatedSession(newSessionId); return; @@ -2515,6 +2539,7 @@ function App() { } }, [ + buildPaneReconnectCwdStartupCommand, closePaneBackendSession, hasFileDocumentDependency, hasPane, @@ -2566,12 +2591,18 @@ function App() { }).catch(() => {}); } const reconnectContent = capturePaneReconnectContent(pane); + const reconnectCwdStartupCommand = buildPaneReconnectCwdStartupCommand(pane); const closed = await closePaneBackendSession(pane); if (!closed) { throw new Error("close_session_failed"); } - const newSessionId = await createSessionForPane(pane); + const newSessionId = await createSessionForPane( + pane, + undefined, + reconnectCwdStartupCommand, + ); + carryOverSessionCwd(pane.sessionId, newSessionId); if (!hasPane(tab.id, pane.id)) { await closeStaleCreatedSession(newSessionId); return; @@ -2608,6 +2639,7 @@ function App() { } }, [ + buildPaneReconnectCwdStartupCommand, closePaneBackendSession, hasFileDocumentDependency, hasPane, @@ -2738,12 +2770,18 @@ function App() { }).catch(() => {}); } const reconnectContent = capturePaneReconnectContent(pane); + const reconnectCwdStartupCommand = buildPaneReconnectCwdStartupCommand(pane); const closed = await closePaneBackendSession(pane); if (!closed) { throw new Error("close_session_failed"); } - const newSessionId = await createSessionForPane(pane); + const newSessionId = await createSessionForPane( + pane, + undefined, + reconnectCwdStartupCommand, + ); + carryOverSessionCwd(pane.sessionId, newSessionId); if (!hasPane(tabId, paneId)) { await closeStaleCreatedSession(newSessionId); return; @@ -2781,6 +2819,7 @@ function App() { } }, [ + buildPaneReconnectCwdStartupCommand, closePaneBackendSession, hasFileDocumentDependency, hasPane, diff --git a/src/components/settings/TerminalTab.tsx b/src/components/settings/TerminalTab.tsx index 61f8440d..c09037e0 100644 --- a/src/components/settings/TerminalTab.tsx +++ b/src/components/settings/TerminalTab.tsx @@ -240,6 +240,20 @@ export function TerminalTab() { } /> + + + updateAppSettings({ + terminal: { ...appSettings.terminal, reconnect_restore_cwd: v }, + }) + } + /> + + { + let disposed = false; + let cwdEventObserved = false; + const unlisten = listen(`cwd-changed-${sessionId}`, (event) => { + cwdEventObserved = true; + recordSessionCwd(sessionId, event.payload); + }); + void unlisten.then(() => { + if (disposed) return; + void invoke("try_get_terminal_cwd", { sessionId }).then( + (cwd) => { + if (disposed || cwdEventObserved) return; + // A successful null snapshot is authoritative and clears a carried cwd. + recordSessionCwd(sessionId, cwd ?? ""); + }, + () => {}, + ); + }); + return () => { + disposed = true; + void unlisten.then((fn) => fn()); + }; + }, [sessionId]); + useEffect(() => { return () => { clearHibernateTimer(); @@ -1071,6 +1101,15 @@ export default function XTerminal({ const createReconnectedSession = () => { const connectionId = connectionIdRef.current; const temporaryConfig = temporaryConfigRef.current; + const settingsSnapshot = terminalAppSettingsRef.current; + const restoreCwdStartupCommand = + sessionTypeRef.current === "SSH" && + (settingsSnapshot.terminal.reconnect_restore_cwd ?? true) + ? buildReconnectCwdStartupCommand( + sessionIdRef.current, + settingsSnapshot.interaction.duplicate_session_command_delay_ms, + ) + : undefined; switch (sessionTypeRef.current) { case "Local": @@ -1108,16 +1147,23 @@ export default function XTerminal({ return invoke("create_serial_session", { connectionId }); default: if (connectionId) { - return invoke("create_ssh_session", { connectionId }); + return invoke("create_ssh_session", { + connectionId, + startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand), + }); } assertTemporaryConfigMatchesSessionType(); if (temporaryConfig?.protocol === "ssh") { const { protocol: _protocol, ...sshConfig } = temporaryConfig; return invoke("create_temporary_ssh_session", { config: sshConfig, + startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand), }); } - return invoke("create_ssh_session", { connectionId }); + return invoke("create_ssh_session", { + connectionId, + startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand), + }); } }; @@ -2075,6 +2121,7 @@ export default function XTerminal({ preservedReconnectContentRef.current = reconnectSnapshot; snapshotRestoreController.begin(reconnectSnapshot); const oldSessionId = sessionIdRef.current; + carryOverSessionCwd(oldSessionId, newSessionId); disconnectedRef.current = false; disconnectedNoticeShownRef.current = false; disconnectedCloseRequestedRef.current = false; diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx index 316c94e0..7e5542a9 100644 --- a/src/context/AppProvider.tsx +++ b/src/context/AppProvider.tsx @@ -151,6 +151,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { timestamp_format: "[HH:mm:ss]", show_multi_line_paste_dialog: true, paste_image_as_path: true, + reconnect_restore_cwd: true, }, interaction: { copy_on_select: false, diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index c44c9572..ac07ad86 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -111,6 +111,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { timestamp_format: "[HH:mm:ss]", show_multi_line_paste_dialog: true, paste_image_as_path: true, + reconnect_restore_cwd: true, }, interaction: { copy_on_select: false, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 67022b49..be813fe6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2221,6 +2221,8 @@ "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.", + "reconnectRestoreCwd": "Restore last working directory on reconnect", + "reconnectRestoreCwdDesc": "After an SSH session reconnects, run cd back 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", "recordingAutoStartDesc": "Automatically start writing a recording file after each session starts. Recording files keep growing until the session ends or recording is stopped manually.", "recordingDefaultMode": "Default Mode", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index a3e9cad4..1021a5f5 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2220,6 +2220,8 @@ "rdpCertificateVerifyNew": "새 RDP 인증서입니다. 아래 지문을 확인하세요.", "rdpCertificateVerifyTitle": "RDP 인증서 검증", "rdpCertificateVerifyWarning": "경고: RDP 인증서가 변경되었습니다. 이 서버를 신뢰할 때만 수락하세요.", + "reconnectRestoreCwd": "재연결 시 마지막 작업 디렉터리 복원", + "reconnectRestoreCwdDesc": "SSH 세션이 재연결되면 연결이 끊어지기 전 작업 디렉터리로 cd 합니다. 셸 통합(bash/zsh/fish)이 필요하며 기록이 없으면 기존 동작을 유지합니다.", "recordingAutoStart": "새 세션 자동 녹화", "recordingAutoStartDesc": "각 세션이 시작된 후 자동으로 녹화 파일 작성을 시작합니다. 녹화 파일은 세션이 끝나거나 수동으로 녹화를 중지할 때까지 계속 커집니다.", "recordingDefaultMode": "기본 모드", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 36532f23..2f043038 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2220,6 +2220,8 @@ "rdpCertificateVerifyNew": "新的 RDP 证书,请验证以下指纹。", "rdpCertificateVerifyTitle": "RDP 证书验证", "rdpCertificateVerifyWarning": "警告:RDP 证书已更改。请仅在你信任此服务器时接受。", + "reconnectRestoreCwd": "重连后恢复上次工作目录", + "reconnectRestoreCwdDesc": "SSH 会话重连成功后自动 cd 回断开前的工作目录。需要 shell 集成(bash/zsh/fish)支持,无记录时保持原有行为。", "recordingAutoStart": "自动录制新会话", "recordingAutoStartDesc": "每次会话启动成功后自动开始写入录制文件。录制文件会持续增长,直到会话结束或手动停止。", "recordingDefaultMode": "默认模式", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 14591c62..a0ae8e74 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2215,6 +2215,8 @@ "rdpCertificateVerifyNew": "新的 RDP 憑證,請驗證以下指紋。", "rdpCertificateVerifyTitle": "RDP 憑證驗證", "rdpCertificateVerifyWarning": "警告:RDP 憑證已更改。請僅在你信任此伺服器時接受。", + "reconnectRestoreCwd": "重連後恢復上次工作目錄", + "reconnectRestoreCwdDesc": "SSH 工作階段重連成功後自動 cd 回中斷前的工作目錄。需要 shell 整合(bash/zsh/fish)支援,無記錄時保持原有行為。", "recordingAutoStart": "自動錄製新工作階段", "recordingAutoStartDesc": "每次工作階段啟動成功後自動開始寫入錄製檔案。錄製檔案會持續增長,直到工作階段結束或手動停止。", "recordingDefaultMode": "預設模式", diff --git a/src/lib/terminalSessionCwd.test.ts b/src/lib/terminalSessionCwd.test.ts new file mode 100644 index 00000000..48ec521b --- /dev/null +++ b/src/lib/terminalSessionCwd.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { + buildReconnectCwdCommand, + buildReconnectCwdStartupCommand, + carryOverSessionCwd, + getSessionCwd, + recordSessionCwd, +} from "./terminalSessionCwd"; + +describe("buildReconnectCwdCommand", () => { + it("returns null for an empty string", () => { + expect(buildReconnectCwdCommand("")).toBeNull(); + }); + + it("returns null for a blank string", () => { + expect(buildReconnectCwdCommand(" ")).toBeNull(); + }); + + it("returns null for paths with control characters", () => { + expect(buildReconnectCwdCommand("/tmp/a\nb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\rb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\0b")).toBeNull(); + }); + + it("returns null for C0 controls beyond \\r\\n\\0", () => { + expect(buildReconnectCwdCommand("/tmp/\x15touch /tmp/pwn #")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x01b")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x1bb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\tb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x1f b")).toBeNull(); + }); + + it("returns null for DEL and C1 control characters", () => { + expect(buildReconnectCwdCommand("/tmp/a\x7fb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x9fb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x85b")).toBeNull(); + }); + + it("wraps a plain path in single quotes", () => { + expect(buildReconnectCwdCommand("/home/user")).toBe("cd '/home/user'"); + }); + + it("keeps spaces inside the quotes", () => { + expect(buildReconnectCwdCommand("/home/my dir")).toBe("cd '/home/my dir'"); + }); + + it("escapes single quotes the POSIX way", () => { + expect(buildReconnectCwdCommand("/home/o'brien")).toBe("cd '/home/o'\\''brien'"); + }); + + it("keeps dollar signs as literals", () => { + expect(buildReconnectCwdCommand("/home/a$b")).toBe("cd '/home/a$b'"); + }); + + it("keeps backslashes as literals", () => { + expect(buildReconnectCwdCommand("/home/a\\b")).toBe("cd '/home/a\\b'"); + }); + + it("supports unicode paths", () => { + expect(buildReconnectCwdCommand("/home/用户")).toBe("cd '/home/用户'"); + }); + + it("decodes percent-encoded paths before replay", () => { + expect(buildReconnectCwdCommand("/opt/my%20dir")).toBe("cd '/opt/my dir'"); + }); + + it("decodes percent-encoded unicode paths before replay", () => { + expect(buildReconnectCwdCommand("/home/%E7%94%A8%E6%88%B7")).toBe( + "cd '/home/用户'", + ); + }); + + it("keeps a raw path when a percent sequence is malformed", () => { + expect(buildReconnectCwdCommand("/opt/100%")).toBe("cd '/opt/100%'"); + expect(buildReconnectCwdCommand("/opt/a%zzb")).toBe("cd '/opt/a%zzb'"); + }); + + it("preserves literal percent sequences from the NyaTerm emitter", () => { + expect(buildReconnectCwdCommand("/opt/100%25")).toBe("cd '/opt/100%'"); + expect(buildReconnectCwdCommand("/opt/my%2520dir")).toBe( + "cd '/opt/my%20dir'", + ); + }); + + it("returns null when decoding leaves a blank value", () => { + expect(buildReconnectCwdCommand("%20")).toBeNull(); + }); + + it("escapes quotes revealed by percent-decoding", () => { + expect(buildReconnectCwdCommand("/home/o%27brien")).toBe( + "cd '/home/o'\\''brien'", + ); + }); + + it("returns null for control characters revealed by percent-decoding", () => { + expect(buildReconnectCwdCommand("/tmp/a%00b")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%0ab")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%0db")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%15b")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%7fb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%C2%85b")).toBeNull(); + }); +}); + +describe("recordSessionCwd / getSessionCwd", () => { + it("round-trips a recorded cwd without consuming it", () => { + recordSessionCwd("cwd-roundtrip", "/var/log"); + expect(getSessionCwd("cwd-roundtrip")).toBe("/var/log"); + expect(getSessionCwd("cwd-roundtrip")).toBe("/var/log"); + }); + + it("removes the record when the payload is empty", () => { + recordSessionCwd("cwd-clear", "/var/log"); + recordSessionCwd("cwd-clear", ""); + expect(getSessionCwd("cwd-clear")).toBeNull(); + }); + + it("removes the record when the payload is blank", () => { + recordSessionCwd("cwd-clear-blank", "/var/log"); + recordSessionCwd("cwd-clear-blank", " "); + expect(getSessionCwd("cwd-clear-blank")).toBeNull(); + }); + + it("returns null for an unknown session", () => { + expect(getSessionCwd("cwd-unknown")).toBeNull(); + }); +}); + +describe("carryOverSessionCwd", () => { + it("copies the cwd to a session without a record", () => { + recordSessionCwd("carry-from", "/opt"); + carryOverSessionCwd("carry-from", "carry-to"); + expect(getSessionCwd("carry-to")).toBe("/opt"); + }); + + it("keeps the target session's own cwd", () => { + recordSessionCwd("carry-from-own", "/opt"); + recordSessionCwd("carry-to-own", "/srv"); + carryOverSessionCwd("carry-from-own", "carry-to-own"); + expect(getSessionCwd("carry-to-own")).toBe("/srv"); + }); + + it("does nothing when the source has no record", () => { + carryOverSessionCwd("carry-from-missing", "carry-to-missing"); + expect(getSessionCwd("carry-to-missing")).toBeNull(); + }); + + it("does nothing for identical session ids", () => { + recordSessionCwd("carry-same", "/opt"); + carryOverSessionCwd("carry-same", "carry-same"); + expect(getSessionCwd("carry-same")).toBe("/opt"); + }); +}); + +describe("buildReconnectCwdStartupCommand", () => { + it("returns undefined when no cwd was recorded", () => { + expect(buildReconnectCwdStartupCommand("startup-unknown", 1000)).toBeUndefined(); + }); + + it("returns undefined when the recorded cwd cannot be replayed", () => { + recordSessionCwd("startup-invalid", " "); + expect(buildReconnectCwdStartupCommand("startup-invalid", 1000)).toBeUndefined(); + }); + + it("builds the startup command with the passed delay", () => { + recordSessionCwd("startup-ok", "/opt/app"); + expect(buildReconnectCwdStartupCommand("startup-ok", 1000)).toEqual({ + command: "cd '/opt/app'", + delayMs: 1000, + }); + }); + + it("passes a zero delay through unchanged", () => { + recordSessionCwd("startup-zero-delay", "/opt/app"); + expect(buildReconnectCwdStartupCommand("startup-zero-delay", 0)).toEqual({ + command: "cd '/opt/app'", + delayMs: 0, + }); + }); +}); diff --git a/src/lib/terminalSessionCwd.ts b/src/lib/terminalSessionCwd.ts new file mode 100644 index 00000000..89ec137c --- /dev/null +++ b/src/lib/terminalSessionCwd.ts @@ -0,0 +1,83 @@ +import type { StartupCommandRequest } from "@/lib/appSessionFactory"; + +const store = (() => { + const globalStore = globalThis as typeof globalThis & { + __nyatermTerminalSessionCwd?: Map; + }; + + globalStore.__nyatermTerminalSessionCwd ??= new Map(); + + return globalStore.__nyatermTerminalSessionCwd; +})(); + +/** + * Records the last known working directory reported by shell integration + * (OSC 7) for a session. An empty/blank payload means the backend cleared + * the cwd (shell integration failed), so the record is removed to avoid + * replaying a stale guess on the next reconnect. + */ +export function recordSessionCwd(sessionId: string, cwd: string) { + if (!cwd.trim()) { + store.delete(sessionId); + return; + } + store.set(sessionId, cwd); +} + +/** Non-destructive read of the last known working directory. */ +export function getSessionCwd(sessionId: string) { + return store.get(sessionId) ?? null; +} + +/** + * Carries the last known cwd over to a reconnected session id + * (copy-if-absent: a cwd reported by the new session itself always wins). + */ +export function carryOverSessionCwd(fromSessionId: string, toSessionId: string) { + if (fromSessionId === toSessionId) return; + if (store.has(toSessionId)) return; + const cwd = store.get(fromSessionId); + if (cwd === undefined) return; + store.set(toSessionId, cwd); +} + +/** C0 controls, DEL, and C1 controls — never replayed as terminal input. */ +const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/; + +/** + * Builds a `cd ''` command for the given cwd. The payload may arrive + * percent-encoded (legacy OSC 7 shell integrations) or raw (NyaTerm's own + * emitter escapes literal '%' characters), so it is best-effort decoded once + * before replay; a malformed percent sequence keeps the raw value. Returns + * null for empty/blank values or paths containing any C0/C1/DEL control + * characters after decoding (terminal line editing could otherwise erase the + * quoted prefix and inject commands) so reconnect falls back to the default + * behavior. + */ +export function buildReconnectCwdCommand(cwd: string) { + if (!cwd.trim()) return null; + let decoded = cwd; + try { + decoded = decodeURIComponent(cwd); + } catch { + // Malformed percent sequence: treat the payload as a raw path. + } + if (!decoded.trim() || CONTROL_CHARS.test(decoded)) return null; + return `cd '${decoded.replace(/'/g, "'\\''")}'`; +} + +/** + * Combines the stored cwd lookup with command building. Returns null when + * nothing should be restored; delayMs is passed through as-is (clamping is + * handled by buildStartupCommandPayload). + */ +export function buildReconnectCwdStartupCommand( + sessionId: string, + delayMs: number, +): StartupCommandRequest | undefined { + const cwd = getSessionCwd(sessionId); + if (cwd === null) return undefined; + const command = buildReconnectCwdCommand(cwd); + if (command === null) return undefined; + return { command, delayMs }; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index f1f598e6..76f88a4b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1359,6 +1359,7 @@ export interface TerminalSettings { timestamp_format: string; show_multi_line_paste_dialog: boolean; paste_image_as_path: boolean; + reconnect_restore_cwd: boolean; } export interface TransferSettings {