Merge pull request #12 from cmliu/beta

Beta
This commit is contained in:
CMLiussss authored and GitHub committed 2026-07-31 05:43:23 +08:00
commit 45938b887b
6 files changed
+373 -133

No files matched your search

+74 -1
View File
@@ -102,6 +102,11 @@ const DISCONNECTED_CHANNEL: LocalizedText = {
en: 'File management connection lost',
};
// Auto-reconnect backoff for the file manager after an unexpected drop.
const RECONNECT_BASE_MS = 1_000;
const RECONNECT_MAX_MS = 15_000;
const RECONNECT_MAX_ATTEMPTS = 10;
function requiredElement<T extends Element>(root: ParentNode, id: string): T {
const node = root.querySelector<T>(`#${id}`);
if (!node) throw new Error(`Missing file manager element #${id}`);
@@ -212,6 +217,14 @@ export class FileManager {
private uploadConfirmationPending = false;
private messageQueue: Promise<void> = Promise.resolve();
private statusCopy: LocalizedText = DISCONNECTED;
/** Auto-reconnect state: true while the SSH session is alive and we want a file channel. */
private wantConnection = false;
/** Last attach URL, saved for reconnection. */
private url: string | null = null;
/** Pending reconnect timer id, null when idle. */
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** Current reconnect attempt count (0 = no attempt). */
private reconnectAttempts = 0;
/** Pending tree-view directory fetch promises, keyed by `tree-` prefixed requestId. */
private treeLists = new Map<string, {
resolve: (value: { path: string; entries: SFTPEntry[]; isTruncated: boolean }) => void;
@@ -233,7 +246,10 @@ export class FileManager {
}
attach(url: string): void {
this.clearReconnectTimer();
this.reset();
this.wantConnection = true;
this.url = url;
let target: URL;
try {
target = new URL(url, window.location.href);
@@ -257,6 +273,7 @@ export class FileManager {
socket.addEventListener('open', () => {
if (!this.isCurrent(socket, generation)) return;
this.reconnectAttempts = 0;
this.init();
});
socket.addEventListener('message', (event: MessageEvent<SocketData>) => {
@@ -277,7 +294,16 @@ export class FileManager {
this.pending.clear();
this.activeListRequest = null;
this.rejectTreeLists(new Error('SFTP connection closed'));
this.renderDisconnected(true, this.describeClose(event));
if (event.code !== 1000 && event.code !== 1005) {
// Unexpected drop. Auto-reconnect unless the user intentionally tore the session down.
if (this.wantConnection) {
this.scheduleReconnect();
} else {
this.renderDisconnected(true, this.describeClose(event));
}
} else {
this.renderDisconnected(true, this.describeClose(event));
}
});
}
@@ -288,6 +314,9 @@ export class FileManager {
}
reset(): void {
this.wantConnection = false;
this.clearReconnectTimer();
this.reconnectAttempts = 0;
this.generation++;
const socket = this.socket;
this.socket = null;
@@ -1186,6 +1215,50 @@ export class FileManager {
this.updateControls();
}
private scheduleReconnect(): void {
if (!this.wantConnection || !this.url || this.reconnectTimer !== null) return;
this.reconnectAttempts++;
if (this.reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
this.reconnectAttempts = 0;
this.renderDisconnected(true, { zh: '文件管理重连失败。', en: 'File manager reconnect failed.' });
return;
}
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * (2 ** (this.reconnectAttempts - 1)));
this.setStatus({
zh: '文件管理连接已断开,正在重连…',
en: 'File manager disconnected; reconnecting…',
});
const attempts = this.reconnectAttempts;
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
if (!this.wantConnection || !this.url) return;
try {
this.attach(this.url);
} catch {
// attach() → reset() zeroed the counter; restore so the next
// close handler's scheduleReconnect() continues accumulating.
if (this.wantConnection) {
this.reconnectAttempts = attempts;
}
this.scheduleReconnect();
return;
}
// Successful attach(); reset() already zeroed the counter.
// Restore so that if a close fires before the 'open' handler
// can confirm the connection, the back-off chain continues.
if (this.wantConnection) {
this.reconnectAttempts = attempts;
}
}, delay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private setStatus(copy: LocalizedText): void {
this.updateStatusText(copy);
this.onStatus?.(this.localize(copy));
+203 -51
View File
@@ -50,6 +50,12 @@ interface TreeNode {
element: HTMLElement | null;
/** Depth in the tree (root = 0). */
depth: number;
/**
* True if the node is on the path to cwd but its SFTP list failed
* (e.g. Permission denied). The node is rendered as a "locked" leaf —
* still visible to preserve the full path, but not expandable.
*/
unavailable: boolean;
}
interface LocalizedText {
@@ -122,11 +128,22 @@ export class FileTree {
private width = DEFAULT_WIDTH;
/** Token to cancel a previous in-progress setCwd expansion (Risk 2). */
private setCwdToken = 0;
/**
* Map tracking in-flight expandNode promises keyed by node.
*
* When a second expandToPath call overlaps with an on-going expandNode
* fetch on the same node (e.g. sftp_ready and sftp_list_result both
* emitCwdChange), the second call retrieves the in-flight promise and
* awaits it instead of bailing out. This prevents the race condition
* where the second expandToPath breaks prematurely because the node
* was still 'loading'.
*/
private inflightExpands = new Map<TreeNode, Promise<void>>();
/**
* Tree-level lifecycle epoch.
*
* `node.loadToken` only guards per-node races; it cannot observe that the
* entire tree was torn down (setReady(false) / destroy() / setRootPath())
* entire tree was torn down (setReady(false) / destroy())
* while a fetch was in flight. Every whole-tree teardown bumps this counter,
* so an awaiting `expandNode` continuation can tell that the node it owns
* has been orphaned and must not touch the DOM, node state, or `onError`.
@@ -187,18 +204,6 @@ export class FileTree {
this.renderRoot();
}
/** Switch the tree's root path (used when cwd escapes the current root). */
setRootPath(path: string): void {
this.rootPath = path;
if (this.ready) {
this.treeEpoch++;
this.root = this.createRootNode(path);
this.focusedNode = this.root;
this.selectedPath = null;
this.renderRoot();
}
}
/**
* Expand ancestor nodes along the path to `cwd` and highlight the target.
* Uses setCwdToken to cancel a previous in-progress expansion (Risk 2).
@@ -206,6 +211,13 @@ export class FileTree {
setCwd(cwd: string): void {
this.selectedPath = cwd;
this.setCwdToken++;
// Kick off root expansion immediately for visual feedback so aria-expanded
// changes to "true" (or shows a spinner) without waiting for the async
// SFTP fetch. silent=true keeps the unavailable-path compatible with
// expandToPath.
if (this.root && this.root.state === 'collapsed') {
void this.expandNode(this.root, true);
}
const token = this.setCwdToken;
void this.expandToPath(cwd, token);
}
@@ -242,6 +254,7 @@ export class FileTree {
loadToken: 0,
element: null,
depth: 0,
unavailable: false,
};
}
@@ -260,12 +273,36 @@ export class FileTree {
loadToken: 0,
element: null,
depth: parent.depth + 1,
unavailable: false,
};
}
/** Whether a node can be expanded (only directories). */
/**
* Create a synthetic "potentially available" child for the next path
* segment when the parent's list failed. The synthetic node starts
* without the unavailable flag — it will be marked unavailable on the
* next fetch attempt if that one also fails.
*/
private createUnavailableChild(path: string, parent: TreeNode): TreeNode {
const name = path.slice(parent.path === '/' ? 1 : parent.path.length + 1);
return {
path,
name,
type: 'directory',
parent,
children: null,
state: 'collapsed',
isTruncated: false,
loadToken: 0,
element: null,
depth: parent.depth + 1,
unavailable: false,
};
}
/** Whether a node can be expanded (only directories, excludes unavailable). */
private isExpandable(node: TreeNode): boolean {
return node.type === 'directory';
return node.type === 'directory' && !node.unavailable;
}
// ── Rendering ───────────────────────────────────────────────────────
@@ -310,6 +347,7 @@ export class FileTree {
if (isSelected) row.classList.add('is-selected');
if (node.state === 'loading') row.classList.add('is-loading');
if (node.state === 'error') row.classList.add('is-error');
if (node.unavailable) row.classList.add('is-unavailable');
row.style.setProperty('--file-tree-depth', String(node.depth));
row.dataset.path = node.path;
@@ -317,12 +355,20 @@ export class FileTree {
const expandable = this.isExpandable(node);
// Toggle / spinner / spacer
// Toggle / spinner / lock / spacer
if (node.state === 'loading') {
const spinner = document.createElement('span');
spinner.className = 'file-tree-spinner';
spinner.setAttribute('aria-hidden', 'true');
row.append(spinner);
} else if (node.unavailable) {
// Lock icon for nodes whose SFTP list failed (e.g. Permission denied
// on an ancestor). Not expandable; preserves full path in the tree.
const lock = document.createElement('span');
lock.className = 'file-tree-lock';
lock.setAttribute('aria-hidden', 'true');
lock.textContent = '\u{1F512}'; // 🔒
row.append(lock);
} else if (expandable) {
const toggle = document.createElement('span');
toggle.className = 'file-tree-toggle';
@@ -367,9 +413,9 @@ export class FileTree {
row.append(retry);
}
// ARIA expanded
// ARIA expanded: treat "loading" as expanded since the expand is in progress.
if (expandable) {
row.setAttribute('aria-expanded', String(node.state === 'expanded'));
row.setAttribute('aria-expanded', String(node.state === 'expanded' || node.state === 'loading'));
} else {
row.removeAttribute('aria-expanded');
}
@@ -437,8 +483,21 @@ export class FileTree {
* - `loadToken` — per-node: a newer expand/collapse superseded this fetch.
* - `treeEpoch` — per-tree: the whole tree was torn down while awaiting.
*/
private async expandNode(node: TreeNode): Promise<void> {
if (!this.isExpandable(node) || node.state === 'expanded' || node.state === 'loading') return;
private async expandNode(node: TreeNode, silent = false): Promise<void> {
// Not expandable or already expanded → no-op.
if (!this.isExpandable(node) || node.state === 'expanded') return;
// Node is currently loading — return the in-flight promise so callers
// (e.g. a second expandToPath) can await the result rather than
// bailing out prematurely. This fixes the race condition triggered
// when FileManager double-emits setCwd (sftp_ready + sftp_list_result).
if (node.state === 'loading') {
const inflight = this.inflightExpands.get(node);
if (inflight) return inflight;
// Defensive fallback: shouldn't happen if inflightExpands is always
// set before transitioning to 'loading'.
return;
}
// Capture the tree generation this call belongs to.
const epoch = this.treeEpoch;
@@ -448,26 +507,54 @@ export class FileTree {
node.state = 'loading';
this.refreshRowContent(node);
// Create the in-flight promise and register it so concurrent callers
// can await it instead of creating a duplicate fetch.
let resolve: () => void = () => {};
const promise = new Promise<void>((res) => { resolve = res; });
this.inflightExpands.set(node, promise);
try {
const result = await this.fetchEntries(node.path);
// Tree-level guard: the tree was destroyed or rebuilt while awaiting,
// so `node` is orphaned. Return without rolling back node.state —
// the node died together with its tree and is unreachable.
if (epoch !== this.treeEpoch || !this.ready || !this.root) return;
if (epoch !== this.treeEpoch || !this.ready || !this.root) { resolve(); return; }
// Per-node race-condition guard: if loadToken changed, this was superseded.
if (token !== node.loadToken) return;
if (token !== node.loadToken) { resolve(); return; }
node.children = result.entries.map((entry) => this.createTreeNode(entry, node));
node.isTruncated = result.isTruncated;
node.state = 'expanded';
this.refreshRowContent(node);
this.renderChildren(node);
resolve();
} catch (error) {
// Tree-level guard MUST run before onError: a fetch rejected by the
// teardown itself (rejectTreeLists on close/destroy) would otherwise
// surface a bogus error toast for a session the user already left.
if (epoch !== this.treeEpoch || !this.ready || !this.root) return;
if (token !== node.loadToken) return;
if (epoch !== this.treeEpoch || !this.ready || !this.root) { resolve(); return; }
if (token !== node.loadToken) { resolve(); return; }
// ── Silent failure handling for setCwd auto-expansion ────────────
// When the tree is auto-walking to cwd and an ancestor fetch fails
// (e.g. restricted shell where / is unreadable), mark the node as
// unavailable instead of re-rooting. The walk will continue via a
// synthetic child in expandToPath, preserving the full path display.
// Manual expand (user click/keyboard) does NOT pass silent=true, so
// those errors still surface via the error path below.
if (silent) {
const cwd = this.selectedPath;
if (cwd && isDescendantPath(node.path, cwd)) {
node.unavailable = true;
node.state = 'collapsed';
node.children = null;
this.removeDescendantRows(node);
this.refreshRowContent(node);
resolve();
return;
}
}
// ── End silent handling ──────────────────────────────────────────
node.state = 'error';
node.children = null;
@@ -475,6 +562,14 @@ export class FileTree {
this.refreshRowContent(node);
const message = error instanceof Error ? error.message : String(error);
this.onError?.(message);
resolve();
} finally {
// Clean up the inflight map entry, but only if our promise is still
// the one registered (prevents clobbering a new expand on the same
// node that started after this one completed/cleaned up).
if (this.inflightExpands.get(node) === promise) {
this.inflightExpands.delete(node);
}
}
}
@@ -499,41 +594,87 @@ export class FileTree {
private async expandToPath(cwd: string, token: number): Promise<void> {
if (!this.ready || !this.root) return;
// If cwd escapes the current root, switch root to cwd
if (!isDescendantPath(this.rootPath, cwd)) {
this.setRootPath(cwd);
if (token !== this.setCwdToken) return;
this.highlightNode(this.root);
this.focusedNode = this.root;
this.updateTabindex();
this.root.element?.scrollIntoView({ block: 'nearest' });
return;
}
let current = this.root;
const segments = splitPathSegments(cwd);
let current = this.root;
for (const segment of segments) {
if (token !== this.setCwdToken) return; // cancelled by a newer setCwd
// Skip the root segment (current is already at root)
if (segment === current.path) continue;
// Ensure current is expanded so we can search its children
if (current.state !== 'expanded') {
await this.expandNode(current);
if (token !== this.setCwdToken) return; // cancelled
if (token !== this.setCwdToken) return;
if (segment === current.path) {
// Even when the segment matches the current node (e.g. root "/" on
// initial load or setCwd("/")), we must still expand it so its
// children are rendered in the tree. Without this, clicking the root
// loads the right panel but the left tree stays collapsed.
if (current.state !== 'expanded') {
await this.expandNode(current, true);
if (token !== this.setCwdToken) return;
}
continue;
}
// Find the child matching this path segment
// If current is unavailable, we can't see its real children.
// Create a synthetic child for the next segment so the walk can
// continue. The synthetic child starts as "potentially available"
// and will be tried on the next iteration.
if (current.unavailable) {
// Check if a synthetic child for this segment already exists
let synth = current.children?.find((c) => c.path === segment) ?? null;
if (!synth) {
synth = this.createUnavailableChild(segment, current);
if (!current.children) current.children = [];
current.children.push(synth);
this.renderChildren(current);
}
current = synth;
continue;
}
// Try to expand current (silent, no error UI on failure).
if (current.state !== 'expanded') {
await this.expandNode(current, true);
if (token !== this.setCwdToken) return;
// Expansion failed and marked current as unavailable: create
// a synthetic child for the next segment.
if (current.unavailable) {
let synth = current.children?.find((c) => c.path === segment) ?? null;
if (!synth) {
synth = this.createUnavailableChild(segment, current);
if (!current.children) current.children = [];
current.children.push(synth);
this.renderChildren(current);
}
current = synth;
continue;
}
}
// Find the real child matching the next segment.
const child = current.children?.find((c) => c.path === segment);
if (!child) break; // path segment not found; highlight deepest ancestor
if (!child) break; // path segment not found in directory
current = child;
}
if (token !== this.setCwdToken) return; // cancelled
if (token !== this.setCwdToken) return;
// If the final node is a synthetic child of an unavailable parent,
// try expanding it (the loop's post-expansion check above may have
// created it and skipped to the next iteration, which then
// short-circuited because segment === current.path).
if (current.state === 'collapsed' && !current.unavailable && current.parent?.unavailable) {
await this.expandNode(current, true);
if (token !== this.setCwdToken) return;
}
// Expand the target directory itself (not just its ancestors) so its
// children are immediately rendered and aria-expanded is "true".
// This covers initial-connection setCwd where activateNode is never
// called — ancestors were expanded during the loop above; the target
// needs this extra step.
if (current.state === 'collapsed' && current.type === 'directory' && !current.unavailable) {
await this.expandNode(current, true);
if (token !== this.setCwdToken) return;
}
// Highlight and scroll to the target node
this.highlightNode(current);
this.focusedNode = current;
this.updateTabindex();
@@ -605,10 +746,21 @@ export class FileTree {
this.highlightNode(node);
this.focusedNode = node;
this.updateTabindex();
if (node.type === 'directory') {
if ((node.type === 'directory' || node.type === 'symlink') && !node.unavailable) {
this.onNavigate(node.path);
// Expand the node in the tree immediately so aria-expanded flips to
// "true" (or shows a loading spinner) without waiting for the async
// setCwd → expandToPath round-trip that follows onNavigate.
// Only real directories are expandable; symlinks navigate the right
// panel but are not expanded in the tree (isExpandable returns false).
if (node.type === 'directory') {
void this.expandNode(node);
}
}
// Files/symlinks: select only, no right-side navigation
// Symlinks: navigate the right panel but are not expandable in the
// tree (isExpandable returns false). Files: select only, no navigation.
// Unavailable directories: highlight but don't navigate (SFTP would
// refuse and surface an error to the user).
}
private handleKeydown(event: KeyboardEvent): void {
+28 -21
View File
@@ -393,6 +393,7 @@ let authorizationAbort: AbortController | null = null;
let currentExpectedFingerprint = '';
let currentRememberedFingerprint = '';
let currentSessionSubtitle: LocalizedMessage = { zh: '选择目标并连接', en: 'Choose a target and connect' };
let currentSessionId = '';
let currentEventMessage: LocalizedMessage = { zh: 'Worker 运行时待命', en: 'Worker runtime standing by' };
let currentFormError: LocalizedMessage | null = null;
let passwordDirty = false;
@@ -402,8 +403,7 @@ let historyPasswordLoadGeneration = 0;
let historyMutationSequence = 0;
let keyFileReadGeneration = 0;
const latestHistoryMutation = new Map<string, number>();
const panelMedia = window.matchMedia('(max-width: 760px)');
let panelOpen = !panelMedia.matches;
let panelOpen = false;
let fileManager: FileManager;
let fileTree: FileTree;
let processManager: ProcessManager;
@@ -1072,6 +1072,16 @@ function toast(message: string, kind: 'info' | 'error' = 'info'): void {
window.setTimeout(() => item.remove(), 4_500);
}
function updateConnectionStatus(message: LocalizedMessage): void {
currentSessionSubtitle = message;
const text = localize(message);
ui.sessionSubtitle.textContent = text;
if (connectionState === 'connecting') {
const btnSpan = ui.connect.querySelector<HTMLElement>('span:last-child');
if (btnSpan) btnSpan.textContent = text;
}
}
function setState(state: ConnectionState, label?: string): void {
connectionState = state;
const stateLabel = label ?? ({
@@ -1098,6 +1108,7 @@ function setState(state: ConnectionState, label?: string): void {
ui.connect.querySelector<HTMLElement>('.button-icon')!.textContent = control.danger ? 'x' : '>_';
ui.connect.querySelector<HTMLElement>('span:last-child')!.textContent = controlLabel;
ui.connect.setAttribute('aria-label', controlLabel);
ui.connect.title = currentSessionId || controlLabel;
}
function event(message: string, category = 'session', error = false, alternate?: string): void {
@@ -1130,13 +1141,13 @@ function fitTerminal(send = true): void {
socket.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows }));
}
} catch {
// The terminal can be temporarily dimensionless during a mobile drawer transition.
// The terminal can be temporarily dimensionless during a panel drawer transition.
}
});
}
function setPanelOpen(open: boolean): void {
const view = resolveConnectionPanel(open, panelMedia.matches);
const view = resolveConnectionPanel(open);
panelOpen = view.expanded;
if (!view.expanded && (connectionState === 'connecting' || connectionState === 'connected' || connectionState === 'disconnecting')) {
invalidateHistoryPasswordLoad();
@@ -1193,13 +1204,12 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive
if (connectionState === 'connected') return;
invalidateHistoryPasswordLoad();
setState('connected');
if (panelMedia.matches) setPanelOpen(false);
setPanelOpen(false);
void saveConnectedProfile().catch(() => {
toast(bilingual('连接成功,但无法更新历史记录。', 'Connected, but the history could not be updated.'), 'error');
});
startTimers();
currentSessionSubtitle = messageTranslation(message);
ui.sessionSubtitle.textContent = message;
updateConnectionStatus(messageTranslation(message));
event(message, 'ready');
if (currentInitialCommand && !initialCommandSent) {
initialCommandSent = true;
@@ -1413,8 +1423,7 @@ function handleServerMessage(message: ServerMessage): void {
if (type === 'status') {
const text = bilingualServerMessage(message.message, message.event, 'SSH handshake in progress');
event(text, message.event ?? 'status');
currentSessionSubtitle = messageTranslation(text);
ui.sessionSubtitle.textContent = text;
updateConnectionStatus(messageTranslation(text));
if (message.event === 'shell_ready' || message.event === 'ready') markReady(text);
}
}
@@ -1457,6 +1466,7 @@ function sendTerminalData(data: string): void {
function failActiveConnection(activeSocket: WebSocket | null, closeReason: string, displayReason: LocalizedMessage): void {
if (activeSocket && socket === activeSocket) socket = null;
connectGeneration++;
currentSessionId = '';
pendingHistory = null;
authorizationAbort?.abort();
authorizationAbort = null;
@@ -1468,8 +1478,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin
processManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
currentSessionSubtitle = displayReason;
ui.sessionSubtitle.textContent = localize(displayReason);
updateConnectionStatus(displayReason);
setState('error');
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(CLIENT_CLOSE_SESSION_ERROR, closeReason);
}
@@ -1537,8 +1546,7 @@ async function connect(): Promise<void> {
awaitingHostKeyDecision = false;
decoder = createDecoder(ui.encoding.value);
ui.sessionTitle.textContent = currentTargetLabel;
currentSessionSubtitle = localized('正在授权 Worker 会话...', 'Authorizing Worker session...');
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
updateConnectionStatus(localized('正在授权 Worker 会话...', 'Authorizing Worker session...'));
ui.metricRtt.textContent = '--';
ui.metricHostKey.textContent = '--';
event(bilingual(`正在连接 ${currentTargetLabel}`, `Starting ${currentTargetLabel}`), 'connect');
@@ -1554,6 +1562,7 @@ async function connect(): Promise<void> {
pendingHistory = { generation, target: currentTargetKey, profile: historyProfile };
const ticketRequest = issueTicket(abortController.signal);
const { ticket, sessionId } = await ticketRequest;
currentSessionId = sessionId;
if (authorizationAbort === abortController) authorizationAbort = null;
if (generation !== connectGeneration) return;
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -1585,8 +1594,7 @@ async function connect(): Promise<void> {
else config.privateKey = privateKey;
if (pinnedKey) config.expectedFingerprint = pinnedKey;
activeSocket.send(JSON.stringify(config));
currentSessionSubtitle = localized('正在打开 TCP 连接...', 'Opening TCP connection...');
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
updateConnectionStatus(localized('正在打开 TCP 连接...', 'Opening TCP connection...'));
event(bilingual('WebSocket 已建立,正在打开 SSH 传输。', 'WebSocket established; opening SSH transport.'), 'transport');
}, { once: true });
activeSocket.addEventListener('message', (socketEvent) => {
@@ -1603,6 +1611,7 @@ async function connect(): Promise<void> {
activeSocket.addEventListener('close', (closeEvent) => {
if (socket !== activeSocket) return;
socket = null;
currentSessionId = '';
pendingHistory = null;
currentExpectedFingerprint = '';
currentRememberedFingerprint = '';
@@ -1619,8 +1628,7 @@ async function connect(): Promise<void> {
? bilingual('会话已关闭。', 'Session closed.')
: bilingual(`会话已关闭(${closeEvent.code})。`, `Session closed (${closeEvent.code}).`);
event(reason, 'disconnect', closeEvent.code !== 1000 && closeEvent.code !== 1005);
currentSessionSubtitle = messageTranslation(reason);
ui.sessionSubtitle.textContent = reason;
updateConnectionStatus(messageTranslation(reason));
setState(closeEvent.code === 1000 || closeEvent.code === 1005 ? 'idle' : 'error');
if (wasActive) toast(reason, closeEvent.code === 1000 || closeEvent.code === 1005 ? 'info' : 'error');
});
@@ -1646,6 +1654,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
if (connectionState === 'disconnecting') return;
const generation = ++connectGeneration;
setState('disconnecting');
currentSessionId = '';
pendingHistory = null;
authorizationAbort?.abort();
authorizationAbort = null;
@@ -1659,8 +1668,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
invalidateHistoryPasswordLoad();
currentExpectedFingerprint = '';
currentRememberedFingerprint = '';
currentSessionSubtitle = messageTranslation(reason);
ui.sessionSubtitle.textContent = reason;
updateConnectionStatus(messageTranslation(reason));
event(reason, 'disconnect');
let settled = false;
const finish = (): void => {
@@ -1947,12 +1955,11 @@ window.addEventListener('beforeunload', () => {
socket?.close(1000, 'Page closed');
});
document.addEventListener('keydown', (keyEvent) => {
if (keyEvent.key === 'Escape' && panelMedia.matches && panelOpen && !ui.hostKeyDialog.open) {
if (keyEvent.key === 'Escape' && panelOpen && !ui.hostKeyDialog.open) {
setPanelOpen(false);
ui.panelToggle.focus();
}
});
panelMedia.addEventListener('change', (mediaEvent) => setPanelOpen(!mediaEvent.matches));
let storedTheme: string | null = null;
try { storedTheme = localStorage.getItem(THEME_STORAGE_KEY); } catch { /* Storage can be disabled. */ }
+49 -46
View File
@@ -204,7 +204,7 @@ button:disabled {
}
#panel-toggle {
display: none;
display: grid;
}
.icon-button:hover {
@@ -359,22 +359,31 @@ button:disabled {
.app-shell {
display: grid;
height: calc(100% - var(--topbar-height));
grid-template-columns: minmax(300px, 340px) minmax(0, 1fr);
transition: grid-template-columns 180ms ease;
grid-template-columns: minmax(0, 1fr);
}
.connection-panel {
position: relative;
z-index: 20;
position: fixed;
top: var(--topbar-height);
bottom: 0;
left: 0;
z-index: 50;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
width: min(90vw, 350px);
padding: 24px 20px max(24px, env(safe-area-inset-bottom));
border-right: 1px solid var(--border);
background: var(--panel);
box-shadow: var(--shadow);
scrollbar-color: var(--surface-3) transparent;
scrollbar-width: thin;
transition: opacity 140ms ease;
transform: translateX(-102%);
transition: transform 220ms cubic-bezier(0.2, 0.75, 0.2, 1);
}
.connection-panel.open {
transform: translateX(0);
}
.panel-heading,
@@ -855,10 +864,18 @@ button:disabled {
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
background: var(--accent);
color: #06130e;
}
.primary-button > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-theme='light'] .primary-button {
color: #fff;
}
@@ -1082,7 +1099,7 @@ button:disabled {
}
.terminal-card.workspace-panel-open {
grid-template-rows: minmax(0, 1fr) minmax(34px, 1fr);
grid-template-rows: minmax(0, 1.618fr) minmax(34px, 1fr);
}
.terminal-pane {
@@ -1519,6 +1536,21 @@ button:disabled {
color: var(--danger);
}
.file-tree-row.is-unavailable {
opacity: 0.55;
}
.file-tree-row.is-unavailable .file-tree-name {
color: var(--color-text-muted, #888);
font-style: italic;
}
.file-tree-lock {
display: inline-block;
width: 12px;
text-align: center;
font-size: 0.85em;
opacity: 0.7;
}
.file-tree-retry {
flex: 0 0 auto;
padding: 0 4px;
@@ -2172,7 +2204,16 @@ button:disabled {
}
.panel-scrim {
display: none;
position: fixed;
inset: var(--topbar-height) 0 0;
z-index: 40;
display: block;
width: auto;
padding: 0;
border: 0;
background: rgba(1, 5, 8, 0.68);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
}
@keyframes toast-in {
@@ -2184,10 +2225,6 @@ button:disabled {
}
@media (max-width: 960px) {
.app-shell {
grid-template-columns: minmax(280px, 310px) minmax(0, 1fr);
}
.session-metrics > div {
min-width: 58px;
padding: 0 5px;
@@ -2241,40 +2278,6 @@ button:disabled {
height: calc(100% - var(--topbar-height));
}
#panel-toggle {
display: grid;
}
.connection-panel {
position: fixed;
top: var(--topbar-height);
bottom: 0;
left: 0;
z-index: 50;
width: min(90vw, 350px);
border-right: 1px solid var(--border);
box-shadow: var(--shadow);
transform: translateX(-102%);
transition: transform 220ms cubic-bezier(0.2, 0.75, 0.2, 1);
}
.connection-panel.open {
transform: translateX(0);
}
.panel-scrim {
position: fixed;
inset: var(--topbar-height) 0 0;
z-index: 40;
display: block;
width: auto;
padding: 0;
border: 0;
background: rgba(1, 5, 8, 0.68);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
}
.workspace {
height: 100%;
padding: 8px;
+4 -4
View File
@@ -20,10 +20,10 @@ export interface ConnectionPanelView {
scrimVisible: boolean;
}
export function resolveConnectionPanel(open: boolean, mobile: boolean): ConnectionPanelView {
export function resolveConnectionPanel(open: boolean): ConnectionPanelView {
return {
expanded: !mobile || open,
drawerOpen: mobile && open,
scrimVisible: mobile && open,
expanded: open,
drawerOpen: open,
scrimVisible: open,
};
}
+15 -10
View File
@@ -18,7 +18,7 @@ interface SFTPAttachToken {
mainWebSocket: WebSocket;
attachUrl: string;
expiresAt: number;
timeout: ReturnType<typeof setTimeout>;
timeout: ReturnType<typeof setTimeout> | null;
session?: SSHSession;
}
type AuxiliaryAttachToken = SFTPAttachToken;
@@ -249,9 +249,9 @@ export class SSHSessionDO implements DurableObject {
}
if (!attach.session) return Response.json({ error: 'SSH session is still initializing' }, { status: 409 });
// Consume before exposing the client endpoint so concurrent replays cannot
// attach a second file-management channel to the same SSH session.
this.deleteSFTPAttachToken(token, attach);
// Keep the token valid after attach so the client can auto-reconnect
// the file manager after a transient connection drop (e.g. code 1006).
// It is deleted in cleanup() when the main SSH session closes.
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
@@ -305,12 +305,17 @@ export class SSHSessionDO implements DurableObject {
}
private registerSFTPAttachToken(token: string, attachUrl: string, mainWebSocket: WebSocket): void {
const expiresAt = Date.now() + SFTP_ATTACH_TOKEN_TTL_MS;
// The SFTP token intentionally lives for the lifetime of the main SSH
// session (it is removed in cleanup() when that session closes). This
// lets the client re-attach the file manager after a transient drop
// (e.g. WebSocket code 1006) instead of being locked out by a one-time
// token. The SSH session setup still gates attach on attach.session
// being ready.
const attach: SFTPAttachToken = {
mainWebSocket,
attachUrl,
expiresAt,
timeout: setTimeout(() => this.deleteSFTPAttachToken(token, attach), SFTP_ATTACH_TOKEN_TTL_MS),
expiresAt: Number.MAX_SAFE_INTEGER,
timeout: null,
};
this.sftpAttachTokens.set(token, attach);
this.sftpTokenByMainWebSocket.set(mainWebSocket, token);
@@ -325,7 +330,7 @@ export class SSHSessionDO implements DurableObject {
mainWebSocket,
attachUrl,
expiresAt: Number.MAX_SAFE_INTEGER,
timeout: 0 as unknown as ReturnType<typeof setTimeout>,
timeout: null,
};
this.processAttachTokens.set(token, attach);
this.processTokenByMainWebSocket.set(mainWebSocket, token);
@@ -334,7 +339,7 @@ export class SSHSessionDO implements DurableObject {
private deleteProcessAttachToken(token: string, expected?: AuxiliaryAttachToken): void {
const attach = this.processAttachTokens.get(token);
if (!attach || (expected && attach !== expected)) return;
clearTimeout(attach.timeout);
if (attach.timeout !== null) clearTimeout(attach.timeout);
this.processAttachTokens.delete(token);
if (this.processTokenByMainWebSocket.get(attach.mainWebSocket) === token) {
this.processTokenByMainWebSocket.delete(attach.mainWebSocket);
@@ -344,7 +349,7 @@ export class SSHSessionDO implements DurableObject {
private deleteSFTPAttachToken(token: string, expected?: SFTPAttachToken): void {
const attach = this.sftpAttachTokens.get(token);
if (!attach || (expected && attach !== expected)) return;
clearTimeout(attach.timeout);
if (attach.timeout !== null) clearTimeout(attach.timeout);
this.sftpAttachTokens.delete(token);
if (this.sftpTokenByMainWebSocket.get(attach.mainWebSocket) === token) {
this.sftpTokenByMainWebSocket.delete(attach.mainWebSocket);