Merge pull request #555 from llyVictory/feat/ssh-session-reconnect
feat(terminal): SSH 重连后自动恢复断联前工作目录
This commit is contained in:
16 files changed
+473
-18
No files matched your search
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2220,6 +2220,8 @@
|
||||
"rdpCertificateVerifyNew": "새 RDP 인증서입니다. 아래 지문을 확인하세요.",
|
||||
"rdpCertificateVerifyTitle": "RDP 인증서 검증",
|
||||
"rdpCertificateVerifyWarning": "경고: RDP 인증서가 변경되었습니다. 이 서버를 신뢰할 때만 수락하세요.",
|
||||
"reconnectRestoreCwd": "재연결 시 마지막 작업 디렉터리 복원",
|
||||
"reconnectRestoreCwdDesc": "SSH 세션이 재연결되면 연결이 끊어지기 전 작업 디렉터리로 cd 합니다. 셸 통합(bash/zsh/fish)이 필요하며 기록이 없으면 기존 동작을 유지합니다.",
|
||||
"recordingAutoStart": "새 세션 자동 녹화",
|
||||
"recordingAutoStartDesc": "각 세션이 시작된 후 자동으로 녹화 파일 작성을 시작합니다. 녹화 파일은 세션이 끝나거나 수동으로 녹화를 중지할 때까지 계속 커집니다.",
|
||||
"recordingDefaultMode": "기본 모드",
|
||||
|
||||
@@ -2220,6 +2220,8 @@
|
||||
"rdpCertificateVerifyNew": "新的 RDP 证书,请验证以下指纹。",
|
||||
"rdpCertificateVerifyTitle": "RDP 证书验证",
|
||||
"rdpCertificateVerifyWarning": "警告:RDP 证书已更改。请仅在你信任此服务器时接受。",
|
||||
"reconnectRestoreCwd": "重连后恢复上次工作目录",
|
||||
"reconnectRestoreCwdDesc": "SSH 会话重连成功后自动 cd 回断开前的工作目录。需要 shell 集成(bash/zsh/fish)支持,无记录时保持原有行为。",
|
||||
"recordingAutoStart": "自动录制新会话",
|
||||
"recordingAutoStartDesc": "每次会话启动成功后自动开始写入录制文件。录制文件会持续增长,直到会话结束或手动停止。",
|
||||
"recordingDefaultMode": "默认模式",
|
||||
|
||||
@@ -2215,6 +2215,8 @@
|
||||
"rdpCertificateVerifyNew": "新的 RDP 憑證,請驗證以下指紋。",
|
||||
"rdpCertificateVerifyTitle": "RDP 憑證驗證",
|
||||
"rdpCertificateVerifyWarning": "警告:RDP 憑證已更改。請僅在你信任此伺服器時接受。",
|
||||
"reconnectRestoreCwd": "重連後恢復上次工作目錄",
|
||||
"reconnectRestoreCwdDesc": "SSH 工作階段重連成功後自動 cd 回中斷前的工作目錄。需要 shell 整合(bash/zsh/fish)支援,無記錄時保持原有行為。",
|
||||
"recordingAutoStart": "自動錄製新工作階段",
|
||||
"recordingAutoStartDesc": "每次工作階段啟動成功後自動開始寫入錄製檔案。錄製檔案會持續增長,直到工作階段結束或手動停止。",
|
||||
"recordingDefaultMode": "預設模式",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+1
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user