diff --git a/frontend/src/file-manager.ts b/frontend/src/file-manager.ts index ab470f3..f62e770 100644 --- a/frontend/src/file-manager.ts +++ b/frontend/src/file-manager.ts @@ -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(root: ParentNode, id: string): T { const node = root.querySelector(`#${id}`); if (!node) throw new Error(`Missing file manager element #${id}`); @@ -212,6 +217,14 @@ export class FileManager { private uploadConfirmationPending = false; private messageQueue: Promise = 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 | 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 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) => { @@ -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)); diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index e6e3bed..55fe72b 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -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>(); /** * 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 { - if (!this.isExpandable(node) || node.state === 'expanded' || node.state === 'loading') return; + private async expandNode(node: TreeNode, silent = false): Promise { + // 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((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 { 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 { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e708936..b0030a3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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(); -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('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('.button-icon')!.textContent = control.danger ? 'x' : '>_'; ui.connect.querySelector('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 { 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 { 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 { 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 { activeSocket.addEventListener('close', (closeEvent) => { if (socket !== activeSocket) return; socket = null; + currentSessionId = ''; pendingHistory = null; currentExpectedFingerprint = ''; currentRememberedFingerprint = ''; @@ -1619,8 +1628,7 @@ async function connect(): Promise { ? 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. */ } diff --git a/frontend/src/style.css b/frontend/src/style.css index 2f954d9..9533d90 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; diff --git a/frontend/src/ui-state.ts b/frontend/src/ui-state.ts index ba8d692..3794143 100644 --- a/frontend/src/ui-state.ts +++ b/frontend/src/ui-state.ts @@ -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, }; } diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index 457414d..88415c5 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -18,7 +18,7 @@ interface SFTPAttachToken { mainWebSocket: WebSocket; attachUrl: string; expiresAt: number; - timeout: ReturnType; + timeout: ReturnType | 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, + 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);