Files
Netcatty/application/state/sessionFactories.ts
T
陈大猫 53a4bf29b0 fix(terminal): retain absent local shell display snapshots (#3674)
Preserve the original local shell name and icon when a saved host is edited and a terminal is copied.

Related to #3615 and #3616.

Validated with 70 focused checks, lint, production build and actual terminal copy flows. Current-head remote review and CI pass.
2026-10-15 14:44:11 +08:00

176 lines
5.8 KiB
TypeScript

import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
import { classifyLocalShellType } from "../../lib/localShell";
export interface LocalTerminalOptions {
shellType?: TerminalSession["shellType"];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
}
/**
* Stable hostId for all Local Terminal sessions.
*
* Autocomplete command history is keyed by hostId. Using `local-${sessionId}`
* made every new Local Terminal look like a brand-new host, so history
* suggestions never accumulated across opens (issue #2037).
*/
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
/**
* [] captures the absence of custom arguments instead of inheriting them
* from a later host edit. The starter still applies backend defaults to []
* and undefined alike. Share the array to preserve pane equality checks.
*/
const EMPTY_LOCAL_SHELL_ARGS: string[] = [];
/**
* Platform string describing the machine that is launching the session.
*
* Vault local hosts can be synced/imported from another OS, so their saved
* `host.os` describes the source machine. When the host has no explicit
* shell (`localShell` empty), `startLocalSession` spawns the current
* machine's OS default shell, so classification must fall back to the
* runtime platform — not the persisted host OS — or a Windows host opened
* on Linux would be tagged "powershell" while Bash actually runs.
*/
const runtimePlatformLike = (): string | undefined => {
if (typeof navigator === "undefined") return undefined;
return navigator.userAgent || navigator.platform || undefined;
};
export const createLocalTerminalSession = (
sessionId: string,
options?: LocalTerminalOptions,
): TerminalSession => ({
id: sessionId,
hostId: LOCAL_TERMINAL_HOST_ID,
hostLabel: options?.shellName || "Local Terminal",
hostname: "localhost",
username: "local",
status: "connecting",
protocol: "local",
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
localStartDir: options?.localStartDir,
});
export const snapshotSerialConfig = (
config: SerialConfig,
legacyBackspaceBehavior?: Host["backspaceBehavior"],
): SerialConfig => ({
...config,
backspaceBehavior: config.backspaceBehavior
?? (legacyBackspaceBehavior === "ctrl-h" ? "ctrl-h" : "default"),
});
export const createSerialTerminalSession = (
sessionId: string,
config: SerialConfig,
options?: { charset?: string },
): TerminalSession => {
const serialConfig = snapshotSerialConfig(config);
const portName = serialConfig.path.split("/").pop() || serialConfig.path;
return {
id: sessionId,
hostId: `serial-${sessionId}`,
hostLabel: `Serial: ${portName}`,
hostname: serialConfig.path,
username: "",
status: "connecting",
protocol: "serial",
serialConfig,
charset: options?.charset,
};
};
export const createHostTerminalSession = (
sessionId: string,
host: Host,
): TerminalSession => {
if (host.protocol === "serial") {
const serialConfig = snapshotSerialConfig(
host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
localEcho: false,
lineMode: false,
},
host.backspaceBehavior,
);
const portName = serialConfig.path.split("/").pop() || serialConfig.path;
return {
id: sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: "",
status: "connecting",
protocol: "serial",
serialConfig,
charset: host.charset,
};
}
return {
id: sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: "connecting",
protocol: host.protocol,
pluginConnection: host.pluginConnection == null
? undefined
: structuredClone(host.pluginConnection),
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
// Local-shell vault hosts carry the per-host shell selection into the
// session (the bridge resolves discovered shell ids at spawn time).
// shellType matches ordinary local-terminal creation so captured-CWD
// restore can reject POSIX injection into CMD/PowerShell sessions.
...(host.protocol === "local" ? {
username: "local",
// With an explicit shell the basename classifies it on any OS, so the
// saved host.os stays the fallback; a default-shell host launches the
// current machine's OS default, so classify against the runtime.
shellType: classifyLocalShellType(
host.localShell,
host.localShell?.trim() ? host.os : runtimePlatformLike(),
),
localShell: host.localShell ?? "",
// Preserve field presence: an edited host must not contribute its args
// to a session that was launched with an explicit shell and no args.
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
localShellName: host.localShellName ?? "",
localShellIcon: host.localShellIcon ?? "",
// Effective directory from resolveHostDefaultLocalShell, including a
// Settings directory the saved host inherits. Copy Tab overlays this
// snapshot, so a later settings or vault edit cannot move the relaunch.
localStartDir: host.localStartDir || "~",
} : {}),
...(host.ephemeral ? { ephemeralHost: true } : {}),
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
};
};
export const createWorkspaceHostTerminalSession = (
sessionId: string,
host: Host,
workspaceId: string,
): TerminalSession => ({
...createHostTerminalSession(sessionId, host),
workspaceId,
});