Merge pull request #555 from llyVictory/feat/ssh-session-reconnect

feat(terminal): SSH 重连后自动恢复断联前工作目录
This commit is contained in:
Kang authored and GitHub committed 2026-09-03 19:25:39 +08:00
commit cd64b8f178
16 files changed
+473 -18

No files matched your search

+15
View File
@@ -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 =
+2 -1
View File
@@ -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"
}
+51 -7
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
if command.trim().is_empty() {
return None;
}
@@ -607,6 +620,12 @@ pub(super) fn build_startup_command_input(command: &str) -> Option<Vec<u8>> {
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<PendingStartupCommand>,
@@ -911,7 +930,7 @@ pub(super) async fn ssh_io_loop(
let mut initial_remote_data_logged = false;
let mut injection_sent_at: Option<Instant> = 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 {
+26 -5
View File
@@ -146,7 +146,8 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option<String>
" }};\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<String>
" __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<String>
" 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"));
+42 -3
View File
@@ -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<SessionPane, "sessionId">) =>
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,
+14
View File
@@ -240,6 +240,20 @@ export function TerminalTab() {
}
/>
<SettingRow
label={t("settings.reconnectRestoreCwd")}
desc={t("settings.reconnectRestoreCwdDesc")}
>
<SettingSwitch
checked={appSettings.terminal.reconnect_restore_cwd ?? true}
onChange={(v) =>
updateAppSettings({
terminal: { ...appSettings.terminal, reconnect_restore_cwd: v },
})
}
/>
</SettingRow>
<SettingInput
label={t("settings.x11Display")}
desc={t("settings.x11DisplayDesc")}
+49 -2
View File
@@ -70,6 +70,12 @@ import {
registerTerminalReconnectCapture,
type TerminalReconnectSnapshot,
} from "@/lib/terminalReconnectHistory";
import {
buildReconnectCwdStartupCommand,
carryOverSessionCwd,
recordSessionCwd,
} from "@/lib/terminalSessionCwd";
import { buildStartupCommandPayload } from "@/lib/appSessionFactory";
import { TERMINAL_SEARCH_VISIBLE_MATCH_LIMIT } from "@/lib/terminalSearch";
import type { AiCaptureEvent } from "@/types/global";
import ActionLinkMenu from "./ActionLinkMenu";
@@ -500,6 +506,30 @@ export default function XTerminal({
};
}, [hibernated, requestWake, sessionId]);
useEffect(() => {
let disposed = false;
let cwdEventObserved = false;
const unlisten = listen<string>(`cwd-changed-${sessionId}`, (event) => {
cwdEventObserved = true;
recordSessionCwd(sessionId, event.payload);
});
void unlisten.then(() => {
if (disposed) return;
void invoke<string | null>("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<string>("create_serial_session", { connectionId });
default:
if (connectionId) {
return invoke<string>("create_ssh_session", { connectionId });
return invoke<string>("create_ssh_session", {
connectionId,
startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand),
});
}
assertTemporaryConfigMatchesSessionType();
if (temporaryConfig?.protocol === "ssh") {
const { protocol: _protocol, ...sshConfig } = temporaryConfig;
return invoke<string>("create_temporary_ssh_session", {
config: sshConfig,
startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand),
});
}
return invoke<string>("create_ssh_session", { connectionId });
return invoke<string>("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;
+1
View File
@@ -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,
+1
View File
@@ -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,
+2
View File
@@ -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",
+2
View File
@@ -2220,6 +2220,8 @@
"rdpCertificateVerifyNew": "새 RDP 인증서입니다. 아래 지문을 확인하세요.",
"rdpCertificateVerifyTitle": "RDP 인증서 검증",
"rdpCertificateVerifyWarning": "경고: RDP 인증서가 변경되었습니다. 이 서버를 신뢰할 때만 수락하세요.",
"reconnectRestoreCwd": "재연결 시 마지막 작업 디렉터리 복원",
"reconnectRestoreCwdDesc": "SSH 세션이 재연결되면 연결이 끊어지기 전 작업 디렉터리로 cd 합니다. 셸 통합(bash/zsh/fish)이 필요하며 기록이 없으면 기존 동작을 유지합니다.",
"recordingAutoStart": "새 세션 자동 녹화",
"recordingAutoStartDesc": "각 세션이 시작된 후 자동으로 녹화 파일 작성을 시작합니다. 녹화 파일은 세션이 끝나거나 수동으로 녹화를 중지할 때까지 계속 커집니다.",
"recordingDefaultMode": "기본 모드",
+2
View File
@@ -2220,6 +2220,8 @@
"rdpCertificateVerifyNew": "新的 RDP 证书,请验证以下指纹。",
"rdpCertificateVerifyTitle": "RDP 证书验证",
"rdpCertificateVerifyWarning": "警告:RDP 证书已更改。请仅在你信任此服务器时接受。",
"reconnectRestoreCwd": "重连后恢复上次工作目录",
"reconnectRestoreCwdDesc": "SSH 会话重连成功后自动 cd 回断开前的工作目录。需要 shell 集成(bash/zsh/fish)支持,无记录时保持原有行为。",
"recordingAutoStart": "自动录制新会话",
"recordingAutoStartDesc": "每次会话启动成功后自动开始写入录制文件。录制文件会持续增长,直到会话结束或手动停止。",
"recordingDefaultMode": "默认模式",
+2
View File
@@ -2215,6 +2215,8 @@
"rdpCertificateVerifyNew": "新的 RDP 憑證,請驗證以下指紋。",
"rdpCertificateVerifyTitle": "RDP 憑證驗證",
"rdpCertificateVerifyWarning": "警告:RDP 憑證已更改。請僅在你信任此伺服器時接受。",
"reconnectRestoreCwd": "重連後恢復上次工作目錄",
"reconnectRestoreCwdDesc": "SSH 工作階段重連成功後自動 cd 回中斷前的工作目錄。需要 shell 整合(bash/zsh/fish)支援,無記錄時保持原有行為。",
"recordingAutoStart": "自動錄製新工作階段",
"recordingAutoStartDesc": "每次工作階段啟動成功後自動開始寫入錄製檔案。錄製檔案會持續增長,直到工作階段結束或手動停止。",
"recordingDefaultMode": "預設模式",
+180
View File
@@ -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,
});
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { StartupCommandRequest } from "@/lib/appSessionFactory";
const store = (() => {
const globalStore = globalThis as typeof globalThis & {
__nyatermTerminalSessionCwd?: Map<string, string>;
};
globalStore.__nyatermTerminalSessionCwd ??= new Map<string, string>();
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 '<path>'` 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 };
}
+1
View File
@@ -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 {