Files
nyaterm/src/lib/externalOpen.ts
T
Kang 7b09cfc152 feat(editor): implement file editor open mode resolution and internal editor display settings
- Added new functions `resolveFileEditorOpenTarget` and `resolveInternalEditorDisplay` to determine the appropriate editor mode based on user settings.
- Introduced a test suite for the file editor open mode to validate the new resolution logic.
- Updated the `FileExplorer` component to utilize the new editor resolution functions, enhancing file handling capabilities.
- Modified the `TransferTab` and context providers to include `internal_editor_display` settings, defaulting to "workspace".
- Enhanced type definitions to support the new editor settings and ensure type safety across components.
2026-08-24 01:14:10 +08:00

497 lines
13 KiB
TypeScript

import type { SavedConnection } from "@/types/global";
import {
parseSshRuntimeMode,
parseTemporaryLink,
type SshRuntimeMode,
type TemporaryLinkConfig,
type TemporarySshLinkConfig,
type TemporaryTelnetLinkConfig,
} from "./temporaryLink";
export type ExternalNetworkOpenProtocol = "ssh" | "telnet";
export type ExternalOpenProtocol = ExternalNetworkOpenProtocol | "local";
export interface ExternalNetworkOpenIntent {
protocol: ExternalNetworkOpenProtocol;
host: string;
port: number;
username: string | null;
usernameSpecified: boolean;
passwordSpecified: boolean;
runtimeMode?: SshRuntimeMode;
runtimeModeSpecified: boolean;
temporary: TemporaryLinkConfig;
}
export interface ExternalLocalOpenIntent {
protocol: "local";
workingDir: string | null;
}
export type ExternalOpenIntent =
| ExternalNetworkOpenIntent
| ExternalLocalOpenIntent;
export type ExternalOpenParseResult =
| { ok: true; intent: ExternalOpenIntent }
| { ok: false; errorKey: string; errorType: string; scheme?: string };
export type ExternalConnectionResolution =
| {
kind: "saved";
connection: SavedConnection;
runtimeModeOverride?: SshRuntimeMode;
}
| {
kind: "ambiguous";
connections: SavedConnection[];
temporary: TemporaryLinkConfig;
runtimeModeOverride?: SshRuntimeMode;
}
| { kind: "temporary"; config: TemporaryLinkConfig }
| { kind: "invalid"; errorKey: string; errorType: string };
const DEFAULT_SSH_PORT = 22;
const DEFAULT_TELNET_PORT = 23;
const DEFAULT_USERNAME = "root";
const UNSUPPORTED_NYATERM_PARAMS = new Set([
"command",
"postlogin",
"post_login",
"identityfile",
"identity_file",
"proxy",
"proxyjump",
"proxy_jump",
"localforward",
"local_forward",
"remoteforward",
"remote_forward",
"dynamicforward",
"dynamic_forward",
"l",
"r",
"d",
]);
export function parseExternalOpenUrl(rawUrl: string): ExternalOpenParseResult {
const trimmed = rawUrl.trim();
if (!trimmed) return invalid("externalOpen.invalidUrl", "empty");
const scheme = schemeOf(trimmed);
if (!scheme) return invalid("externalOpen.invalidUrl", "invalid_url");
if (scheme === "ssh" || scheme === "telnet") {
return parseExternalTemporaryUrl(trimmed, scheme);
}
if (scheme === "nyaterm") {
return parseNyatermUrl(trimmed);
}
return invalid(
"externalOpen.unsupportedProtocol",
"unsupported_scheme",
scheme,
);
}
export function findExternalConnectionMatches(
savedConnections: SavedConnection[],
intent: ExternalNetworkOpenIntent,
): ExternalConnectionResolution {
if (intent.protocol === "ssh" && intent.passwordSpecified) {
return { kind: "temporary", config: intent.temporary };
}
const matches = savedConnections.filter((connection) => {
if (connection.type !== intent.protocol) return false;
if (normalizeHostForMatch(connection.host ?? "") !== intent.host)
return false;
if (
normalizePortForProtocol(connection.port, intent.protocol) !== intent.port
)
return false;
if (intent.protocol === "ssh" && intent.usernameSpecified) {
return (connection.username ?? "") === intent.username;
}
return true;
});
const runtimeModeOverride =
intent.protocol === "ssh" && intent.runtimeModeSpecified
? intent.runtimeMode
: undefined;
if (matches.length === 1) {
return { kind: "saved", connection: matches[0], runtimeModeOverride };
}
if (matches.length > 1) {
return {
kind: "ambiguous",
connections: matches,
temporary: intent.temporary,
runtimeModeOverride,
};
}
return { kind: "temporary", config: intent.temporary };
}
function parseExternalTemporaryUrl(
rawUrl: string,
protocol: ExternalNetworkOpenProtocol,
): ExternalOpenParseResult {
const parsed = parseTemporaryLink(protocol, rawUrl);
if (!parsed.ok) {
if (hasInvalidExplicitUrlPort(rawUrl)) {
return invalid("externalOpen.invalidPort", "invalid_port", protocol);
}
return invalid(
mapTemporaryErrorKey(parsed.errorKey),
temporaryErrorType(parsed.errorKey),
protocol,
);
}
if (parsed.config.protocol !== protocol) {
return invalid("externalOpen.invalidUrl", "invalid_protocol", protocol);
}
const url = new URL(rawUrl);
const host = normalizeHostForMatch(parsed.config.host);
const port = normalizePortForProtocol(parsed.config.port, protocol);
if (protocol === "ssh") {
const sshConfig = parsed.config as TemporarySshLinkConfig;
const usernameSpecified = url.username.length > 0;
const passwordSpecified = temporarySshPasswordSpecified(sshConfig);
const runtimeModeSpecified = url.searchParams.has("mode");
return {
ok: true,
intent: {
protocol,
host,
port,
username: usernameSpecified ? sshConfig.username : null,
usernameSpecified,
passwordSpecified,
runtimeMode: sshConfig.runtime_mode,
runtimeModeSpecified,
temporary: normalizeTemporaryConfig(sshConfig, host, port),
},
};
}
return {
ok: true,
intent: {
protocol,
host,
port,
username: null,
usernameSpecified: false,
passwordSpecified: false,
runtimeModeSpecified: false,
temporary: normalizeTemporaryConfig(parsed.config, host, port),
},
};
}
function parseNyatermUrl(rawUrl: string): ExternalOpenParseResult {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return invalid("externalOpen.invalidUrl", "invalid_url", "nyaterm");
}
if (url.hostname.toLowerCase() !== "connect") {
return invalid("externalOpen.invalidUrl", "invalid_path", "nyaterm");
}
const protocol = url.pathname.replace(/^\/+/, "").toLowerCase();
if (protocol !== "ssh" && protocol !== "telnet" && protocol !== "local") {
return invalid(
"externalOpen.unsupportedProtocol",
"unsupported_scheme",
"nyaterm",
);
}
const params = url.searchParams;
if (protocol === "local") {
return parseNyatermLocalUrl(params);
}
for (const key of params.keys()) {
const normalizedKey = key.toLowerCase();
if (normalizedKey === "password") {
return invalid(
"externalOpen.inlinePassword",
"inline_password",
"nyaterm",
);
}
if (protocol !== "ssh" && normalizedKey === "mode") {
return invalid(
"externalOpen.unsupportedParameter",
"unsupported_parameter",
"nyaterm",
);
}
if (UNSUPPORTED_NYATERM_PARAMS.has(normalizedKey)) {
return invalid(
"externalOpen.unsupportedParameter",
"unsupported_parameter",
"nyaterm",
);
}
}
const host = normalizeHostForMatch(params.get("host") ?? "");
if (!host)
return invalid("externalOpen.missingHost", "missing_host", "nyaterm");
const port = parsePort(params.get("port"), protocol);
if (port === null)
return invalid("externalOpen.invalidPort", "invalid_port", "nyaterm");
if (protocol === "ssh") {
const username = (params.get("username") ?? DEFAULT_USERNAME).trim();
if (!username)
return invalid("externalOpen.invalidUrl", "missing_username", "nyaterm");
const runtimeMode = parseSshRuntimeMode(params.get("mode"));
if (!runtimeMode)
return invalid("externalOpen.invalidMode", "invalid_mode", "nyaterm");
return {
ok: true,
intent: {
protocol,
host,
port,
username,
usernameSpecified: params.has("username"),
passwordSpecified: false,
runtimeMode,
runtimeModeSpecified: params.has("mode"),
temporary: createTemporarySshConfig(
host,
port,
username,
null,
runtimeMode,
),
},
};
}
if (params.has("username")) {
return invalid(
"externalOpen.invalidUrl",
"unsupported_username",
"nyaterm",
);
}
return {
ok: true,
intent: {
protocol,
host,
port,
username: null,
usernameSpecified: false,
passwordSpecified: false,
runtimeModeSpecified: false,
temporary: createTemporaryTelnetConfig(host, port),
},
};
}
function normalizeTemporaryConfig(
config: TemporaryLinkConfig,
host: string,
port: number,
): TemporaryLinkConfig {
if (config.protocol === "ssh") {
return createTemporarySshConfig(
host,
port,
config.username,
temporarySshPassword(config),
config.runtime_mode,
);
}
if (config.protocol === "telnet") {
return createTemporaryTelnetConfig(host, port);
}
return config;
}
function createTemporarySshConfig(
host: string,
port: number,
username: string,
password: string | null = null,
runtimeMode: SshRuntimeMode = "standard",
): TemporarySshLinkConfig {
return {
protocol: "ssh",
runtime_mode: runtimeMode,
name: `${username}@${host}:${port}`,
host,
port,
username,
auth: { type: "password", password },
backspace_mode: "del",
x11_forwarding: false,
x11_display: "",
proxy: null,
proxy_jump: null,
post_login: null,
};
}
function temporarySshPassword(config: TemporarySshLinkConfig) {
return config.auth.type === "password" && config.auth.password
? config.auth.password
: null;
}
function temporarySshPasswordSpecified(config: TemporaryLinkConfig) {
return config.protocol === "ssh" && temporarySshPassword(config) !== null;
}
function createTemporaryTelnetConfig(
host: string,
port: number,
): TemporaryTelnetLinkConfig {
return {
protocol: "telnet",
name: `telnet://${host}:${port}`,
host,
port,
};
}
function normalizeHostForMatch(host: string): string {
const trimmed = host.trim().replace(/^\[(.*)\]$/, "$1");
if (!trimmed) return "";
return trimmed.includes(":") ? trimmed : trimmed.toLowerCase();
}
function normalizePortForProtocol(
port: number | undefined,
protocol: ExternalNetworkOpenProtocol,
) {
return port && Number.isInteger(port)
? port
: protocol === "ssh"
? DEFAULT_SSH_PORT
: DEFAULT_TELNET_PORT;
}
function parsePort(value: string | null, protocol: ExternalNetworkOpenProtocol) {
if (!value)
return protocol === "ssh" ? DEFAULT_SSH_PORT : DEFAULT_TELNET_PORT;
if (!/^\d+$/.test(value)) return null;
const port = Number(value);
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
}
function parseNyatermLocalUrl(params: URLSearchParams): ExternalOpenParseResult {
for (const key of params.keys()) {
const normalizedKey = key.toLowerCase();
if (normalizedKey === "password") {
return invalid(
"externalOpen.inlinePassword",
"inline_password",
"nyaterm",
);
}
if (normalizedKey !== "cwd") {
return invalid(
"externalOpen.unsupportedParameter",
"unsupported_parameter",
"nyaterm",
);
}
}
const workingDir = params.get("cwd");
if (workingDir !== null && workingDir.trim() === "") {
return invalid("externalOpen.invalidUrl", "empty_cwd", "nyaterm");
}
return {
ok: true,
intent: {
protocol: "local",
workingDir,
},
};
}
function mapTemporaryErrorKey(errorKey: string) {
if (errorKey === "temporarySsh.inlinePassword")
return "externalOpen.inlinePassword";
if (errorKey === "temporarySsh.unsupportedOption")
return "externalOpen.unsupportedParameter";
if (errorKey === "temporarySsh.invalidPort")
return "externalOpen.invalidPort";
if (errorKey === "temporarySsh.invalidMode")
return "externalOpen.invalidMode";
if (errorKey === "temporarySsh.missingHost")
return "externalOpen.missingHost";
return "externalOpen.invalidUrl";
}
function temporaryErrorType(errorKey: string) {
if (errorKey === "temporarySsh.inlinePassword") return "inline_password";
if (errorKey === "temporarySsh.unsupportedOption")
return "unsupported_parameter";
if (errorKey === "temporarySsh.invalidPort") return "invalid_port";
if (errorKey === "temporarySsh.invalidMode") return "invalid_mode";
if (errorKey === "temporarySsh.missingHost") return "missing_host";
return "invalid_url";
}
function invalid(
errorKey: string,
errorType: string,
scheme?: string,
): ExternalOpenParseResult {
return { ok: false, errorKey, errorType, scheme };
}
function schemeOf(value: string) {
const index = value.indexOf(":");
if (index <= 0) return null;
const scheme = value.slice(0, index);
if (!/^[a-z][a-z0-9+.-]*$/i.test(scheme)) return null;
return scheme.toLowerCase();
}
function hasInvalidExplicitUrlPort(value: string) {
const authority = value.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i)?.[1];
if (!authority) return false;
const hostPort = authority.slice(authority.lastIndexOf("@") + 1);
let portText: string | null = null;
if (hostPort.startsWith("[")) {
const end = hostPort.indexOf("]");
if (end >= 0 && hostPort[end + 1] === ":") {
portText = hostPort.slice(end + 2);
}
} else {
const colonCount = (hostPort.match(/:/g) ?? []).length;
if (colonCount === 1) {
portText = hostPort.slice(hostPort.indexOf(":") + 1);
}
}
if (portText === null) return false;
if (!/^\d+$/.test(portText)) return true;
const port = Number(portText);
return !Number.isInteger(port) || port < 1 || port > 65535;
}