Files
NetMesh/domain/sftpConnectedHosts.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

176 lines
6.5 KiB
TypeScript

import type { Host, TerminalSession } from "./models";
import { isPluginHostProtocol } from "./pluginConnection";
export type SftpConnectedHostEntry = {
host: Host;
sessionId: string;
status: "connected";
};
/** Fields the SFTP Connected picker cares about from a terminal session. */
export type SftpPickerSessionFields = Pick<
TerminalSession,
| "id"
| "hostId"
| "protocol"
| "status"
| "moshEnabled"
| "etEnabled"
| "hostname"
| "username"
| "port"
>;
/**
* Sessions that can actually reuse a live terminal SSH connection for SFTP.
* Connecting sessions and Mosh/ET transports have no reusable ssh2 shell conn.
*/
const isReusableSftpSourceSession = (session: SftpPickerSessionFields): boolean => {
if (session.id.startsWith("sftp-")) return false;
if (session.status !== "connected") return false;
if (session.moshEnabled || session.etEnabled) return false;
const protocol = session.protocol;
if (protocol === "serial" || protocol === "local" || protocol === "telnet"
|| isPluginHostProtocol(protocol)) return false;
// Missing protocol defaults to SSH (same as host picker filtering).
return true;
};
/**
* Overlay the live session endpoint onto the vault host so SFTP connect +
* sourceSessionId reuse matches findReusableSession's endpoint check.
* Vault hosts can be edited after the terminal connected; using the edited
* host would reject reuse and open a fresh connection to a different target.
*/
const hostForLiveSession = (host: Host, session: SftpPickerSessionFields): Host => ({
...host,
hostname: session.hostname,
username: session.username,
// Prefer the session port; when undefined the live SSH default is 22.
// Do not fall back to vault host.port — it may have been edited after connect.
port: session.port ?? 22,
});
/** True when two hosts target the same SSH endpoint (hostname/user/port). */
export const sftpHostEndpointsEqual = (
a: Pick<Host, "hostname" | "username" | "port">,
b: Pick<Host, "hostname" | "username" | "port">,
): boolean =>
a.hostname === b.hostname
&& a.username === b.username
&& (a.port ?? 22) === (b.port ?? 22);
/**
* Compare only picker-relevant session fields so title/cwd/font churn does not
* invalidate side-panel memoization.
*/
export const sftpPickerSessionsEqual = (
prev: ReadonlyArray<SftpPickerSessionFields> | null | undefined,
next: ReadonlyArray<SftpPickerSessionFields> | null | undefined,
): boolean => {
if (prev === next) return true;
if (!prev || !next) return false;
if (prev.length !== next.length) return false;
// Compare positionally: listSftpConnectedHosts keeps the later same-host
// session, so a reorder with identical members must invalidate memoization.
for (let i = 0; i < prev.length; i += 1) {
const session = prev[i];
const other = next[i];
if (!session || !other) return false;
if (
session.id !== other.id
|| session.hostId !== other.hostId
|| session.protocol !== other.protocol
|| session.status !== other.status
|| Boolean(session.moshEnabled) !== Boolean(other.moshEnabled)
|| Boolean(session.etEnabled) !== Boolean(other.etEnabled)
|| session.hostname !== other.hostname
|| session.username !== other.username
|| (session.port ?? 22) !== (other.port ?? 22)
) {
return false;
}
}
return true;
};
/**
* Build the "currently connected" host list for the SFTP host picker.
* One entry per hostId — keeps the most recently listed SSH terminal session.
*
* Includes hosts with sftpSudo: they still belong under "Connected" when a
* terminal is open, and SFTP can borrow the already-authenticated SSH transport
* before silently falling back to a fresh connection.
*/
export const listSftpConnectedHosts = (
sessions: ReadonlyArray<SftpPickerSessionFields>,
hostsById: ReadonlyMap<string, Host>,
): SftpConnectedHostEntry[] => {
const bestByHostId = new Map<string, SftpConnectedHostEntry>();
for (const session of sessions) {
if (!isReusableSftpSourceSession(session)) continue;
const host = hostsById.get(session.hostId);
if (!host) continue;
if (host.protocol === "serial" || isPluginHostProtocol(host.protocol)) continue;
// Use session transport flags only. Vault hosts may still have mosh/et
// defaults while the live terminal was opened as plain SSH (e.g. ssh://).
// Do not filter sftpSudo here — picker display only; reuse is stripped later.
// Later sessions overwrite earlier ones for the same hostId.
bestByHostId.set(host.id, {
host: hostForLiveSession(host, session),
sessionId: session.id,
status: "connected",
});
}
return [...bestByHostId.values()].sort((a, b) =>
a.host.label.localeCompare(b.host.label),
);
};
/**
* Return the live terminal session id that SFTP should try before fresh auth.
* The main process validates that the hinted session really matches the target.
*/
export const sftpSourceSessionIdForHost = (
host: Pick<Host, "sftpSudo"> | null | undefined,
sourceSessionId: string | undefined,
): string | undefined => {
if (!sourceSessionId) return undefined;
return sourceSessionId;
};
/**
* Resolve a terminal session id for transfer-pool opens that can reuse the
* live SSH transport. Unlike the picker list, this keeps every reusable
* session so same-hostId tabs with different live endpoints can still match.
* This renderer-side result is only a source hint: proxy, jump, credential,
* host-key and keepalive fingerprints are intentionally retained by the main
* process. openSftpForSession reselects and validates the live session against
* that complete identity before it borrows a transport.
*
* @returns session id, or undefined when no reusable source exists
*/
export const resolveSftpTransferSourceSessionId = (
sessions: ReadonlyArray<SftpPickerSessionFields>,
hostsById: ReadonlyMap<string, Host>,
hostId: string,
host?: Pick<Host, "hostname" | "username" | "port" | "sftpSudo" | "protocol">,
): string | undefined => {
let lastMatch: string | undefined;
for (const session of sessions) {
if (session.hostId !== hostId) continue;
if (!isReusableSftpSourceSession(session)) continue;
const vaultHost = hostsById.get(session.hostId);
if (!vaultHost) continue;
if (vaultHost.protocol === "serial" || isPluginHostProtocol(vaultHost.protocol)) continue;
const liveHost = hostForLiveSession(vaultHost, session);
if (host && !sftpHostEndpointsEqual(liveHost, host)) continue;
lastMatch = session.id;
}
return lastMatch;
};