From bb3f260faceb1a667ec5cd6a1e9d8ce554a1315d Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 01:44:37 +0800 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=20expandNode=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E7=9A=84=20silent=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E9=9D=99=E9=BB=98=E9=87=8D=E6=A0=B9?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-tree.ts | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index e6e3bed..44ed654 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -437,7 +437,7 @@ 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 { + private async expandNode(node: TreeNode, silent = false): Promise { if (!this.isExpandable(node) || node.state === 'expanded' || node.state === 'loading') return; // Capture the tree generation this call belongs to. @@ -469,6 +469,32 @@ export class FileTree { if (epoch !== this.treeEpoch || !this.ready || !this.root) return; if (token !== node.loadToken) return; + // ── Silent re-rooting for setCwd auto-expansion ───────────────── + // When setCwd triggers expandToPath and an ancestor node fetch fails + // (e.g. Permission denied on root in a restricted shell), silently + // re-root the tree to cwd so users can still browse their accessible + // directory. 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)) { + if (node.path !== cwd) { + // Failed node is a strict ancestor of cwd: switch root to cwd + // then expand the new root so the user sees their directory. + this.setRootPath(cwd); + const newRoot = this.root; + if (newRoot) void this.expandNode(newRoot); + } else { + // Failed node is cwd itself: leave it as a collapsed leaf + // without surfacing an error. + node.state = 'collapsed'; + this.refreshRowContent(node); + } + return; + } + } + // ── End silent re-rooting ──────────────────────────────────────── + node.state = 'error'; node.children = null; this.removeDescendantRows(node); @@ -521,7 +547,7 @@ export class FileTree { // Ensure current is expanded so we can search its children if (current.state !== 'expanded') { - await this.expandNode(current); + await this.expandNode(current, true); if (token !== this.setCwdToken) return; // cancelled } From 1e11c7c42aae9678076c1e20c3b7102b6675cb87 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 02:04:12 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8=E8=8A=82=E7=82=B9=E7=9A=84=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96=E6=96=87=E4=BB=B6=E6=A0=91?= =?UTF-8?q?=E7=9A=84=E6=A0=B7=E5=BC=8F=E5=92=8C=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-tree.ts | 154 ++++++++++++++++++++++++++------------ frontend/src/style.css | 15 ++++ 2 files changed, 123 insertions(+), 46 deletions(-) diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index 44ed654..3c7c482 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 { @@ -242,6 +248,7 @@ export class FileTree { loadToken: 0, element: null, depth: 0, + unavailable: false, }; } @@ -260,12 +267,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 +341,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 +349,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'; @@ -469,31 +509,25 @@ export class FileTree { if (epoch !== this.treeEpoch || !this.ready || !this.root) return; if (token !== node.loadToken) return; - // ── Silent re-rooting for setCwd auto-expansion ───────────────── - // When setCwd triggers expandToPath and an ancestor node fetch fails - // (e.g. Permission denied on root in a restricted shell), silently - // re-root the tree to cwd so users can still browse their accessible - // directory. Manual expand (user click/keyboard) does NOT pass - // silent=true, so those errors still surface via the error path below. + // ── 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)) { - if (node.path !== cwd) { - // Failed node is a strict ancestor of cwd: switch root to cwd - // then expand the new root so the user sees their directory. - this.setRootPath(cwd); - const newRoot = this.root; - if (newRoot) void this.expandNode(newRoot); - } else { - // Failed node is cwd itself: leave it as a collapsed leaf - // without surfacing an error. - node.state = 'collapsed'; - this.refreshRowContent(node); - } + node.unavailable = true; + node.state = 'collapsed'; + node.children = null; + this.removeDescendantRows(node); + this.refreshRowContent(node); return; } } - // ── End silent re-rooting ──────────────────────────────────────── + // ── End silent handling ────────────────────────────────────────── node.state = 'error'; node.children = null; @@ -525,41 +559,67 @@ 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 (token !== this.setCwdToken) return; if (segment === current.path) continue; - // Ensure current is expanded so we can search its children - if (current.state !== 'expanded') { - await this.expandNode(current, true); - if (token !== this.setCwdToken) return; // cancelled + // 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; } - // Find the child matching this path segment + // 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; + } - // Highlight and scroll to the target node this.highlightNode(current); this.focusedNode = current; this.updateTabindex(); @@ -631,10 +691,12 @@ export class FileTree { this.highlightNode(node); this.focusedNode = node; this.updateTabindex(); - if (node.type === 'directory') { + if (node.type === 'directory' && !node.unavailable) { this.onNavigate(node.path); } // Files/symlinks: select only, no right-side 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/style.css b/frontend/src/style.css index 2f954d9..aadd1d1 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1519,6 +1519,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; From 26acd6e5cc1eb8c971d18498e237204e364a9134 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 02:28:26 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=EF=BC=8C=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E8=BF=9E=E6=8E=A5=E9=9D=A2=E6=9D=BF=E7=9A=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=92=8C=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/main.ts | 12 +++---- frontend/src/style.css | 70 ++++++++++++++-------------------------- frontend/src/ui-state.ts | 8 ++--- 3 files changed, 34 insertions(+), 56 deletions(-) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e708936..f7fad0c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -402,8 +402,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; @@ -1130,13 +1129,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,7 +1192,7 @@ 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'); }); @@ -1947,12 +1946,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 aadd1d1..4876c14 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, @@ -2187,7 +2196,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 { @@ -2199,10 +2217,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; @@ -2256,40 +2270,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, }; } From b68c04019ab0fafc87378eb475557cbd5a92b24a Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 02:47:08 +0800 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E5=AF=B9=20expa?= =?UTF-8?q?ndNode=20=E6=96=B9=E6=B3=95=E7=9A=84=E5=B9=B6=E5=8F=91=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E4=BC=98=E5=8C=96=E8=8A=82=E7=82=B9=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E9=80=BB=E8=BE=91=E4=BB=A5=E9=98=B2=E6=AD=A2=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-tree.ts | 50 +++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index 3c7c482..e00f49a 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -128,6 +128,17 @@ 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. * @@ -478,7 +489,20 @@ export class FileTree { * - `treeEpoch` — per-tree: the whole tree was torn down while awaiting. */ private async expandNode(node: TreeNode, silent = false): Promise { - if (!this.isExpandable(node) || node.state === 'expanded' || node.state === 'loading') return; + // 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; @@ -488,26 +512,33 @@ 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 @@ -524,6 +555,7 @@ export class FileTree { node.children = null; this.removeDescendantRows(node); this.refreshRowContent(node); + resolve(); return; } } @@ -535,6 +567,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); + } } } From 25504d54c231e1cbd3c95ed38ac8f13f25294065 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 03:05:38 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E7=8A=B6=E6=80=81=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BC=9A=E8=AF=9D=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E5=92=8C=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/main.ts | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index f7fad0c..9ae4b5c 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; @@ -1071,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 ?? ({ @@ -1087,7 +1098,7 @@ function setState(state: ConnectionState, label?: string): void { const controlLabel = control.action === 'cancel' ? bilingual('取消连接', 'Cancel connection') : control.action === 'disconnect' - ? bilingual('断开', 'Disconnect') + ? (currentSessionId ? `${currentSessionId} ${bilingual('断开', 'Disconnect')}` : bilingual('断开', 'Disconnect')) : control.action === 'disconnecting' ? bilingual('断开中...', 'Disconnecting...') : bilingual('连接', 'Connect'); @@ -1197,8 +1208,7 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive 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; @@ -1412,8 +1422,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); } } @@ -1456,6 +1465,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; @@ -1467,8 +1477,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); } @@ -1536,8 +1545,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'); @@ -1553,6 +1561,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:'; @@ -1584,8 +1593,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) => { @@ -1602,6 +1610,7 @@ async function connect(): Promise { activeSocket.addEventListener('close', (closeEvent) => { if (socket !== activeSocket) return; socket = null; + currentSessionId = ''; pendingHistory = null; currentExpectedFingerprint = ''; currentRememberedFingerprint = ''; @@ -1618,8 +1627,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'); }); @@ -1645,6 +1653,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected if (connectionState === 'disconnecting') return; const generation = ++connectGeneration; setState('disconnecting'); + currentSessionId = ''; pendingHistory = null; authorizationAbort?.abort(); authorizationAbort = null; @@ -1658,8 +1667,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 => { From 8cb3c4053cc0765833b16b1f01a26f34c617efe8 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 03:10:44 +0800 Subject: [PATCH 06/15] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E4=BC=9A?= =?UTF-8?q?=E8=AF=9DID=E7=9A=84=E6=98=BE=E7=A4=BA=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=96=87=E6=9C=AC=E6=BA=A2=E5=87=BA?= =?UTF-8?q?=E5=A4=84=E7=90=86=E4=BB=A5=E6=8F=90=E5=8D=87=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/main.ts | 9 ++++++++- frontend/src/style.css | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 9ae4b5c..61b9134 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1082,6 +1082,11 @@ function updateConnectionStatus(message: LocalizedMessage): void { } } +function formatSessionId(id: string): string { + if (!id) return ''; + return id.length > 8 ? `${id.slice(0, 8)}…` : id; +} + function setState(state: ConnectionState, label?: string): void { connectionState = state; const stateLabel = label ?? ({ @@ -1095,10 +1100,11 @@ function setState(state: ConnectionState, label?: string): void { ui.liveOrbLabel.textContent = stateLabel; ui.liveOrb.title = stateLabel; const control = resolveConnectionControl(state, historyPasswordLoading); + const sessionPrefix = formatSessionId(currentSessionId); const controlLabel = control.action === 'cancel' ? bilingual('取消连接', 'Cancel connection') : control.action === 'disconnect' - ? (currentSessionId ? `${currentSessionId} ${bilingual('断开', 'Disconnect')}` : bilingual('断开', 'Disconnect')) + ? (sessionPrefix ? `${sessionPrefix} ${bilingual('断开', 'Disconnect')}` : bilingual('断开', 'Disconnect')) : control.action === 'disconnecting' ? bilingual('断开中...', 'Disconnecting...') : bilingual('连接', 'Connect'); @@ -1108,6 +1114,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 { diff --git a/frontend/src/style.css b/frontend/src/style.css index 4876c14..944686f 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -864,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; } From a01b9ab5e7f06944067734703cc4ae76c312f3d5 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 03:15:22 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E4=BC=9A?= =?UTF-8?q?=E8=AF=9DID=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=87=BD=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E7=AE=80=E5=8C=96=E8=BF=9E=E6=8E=A5=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/main.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 61b9134..b0030a3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1082,11 +1082,6 @@ function updateConnectionStatus(message: LocalizedMessage): void { } } -function formatSessionId(id: string): string { - if (!id) return ''; - return id.length > 8 ? `${id.slice(0, 8)}…` : id; -} - function setState(state: ConnectionState, label?: string): void { connectionState = state; const stateLabel = label ?? ({ @@ -1100,11 +1095,10 @@ function setState(state: ConnectionState, label?: string): void { ui.liveOrbLabel.textContent = stateLabel; ui.liveOrb.title = stateLabel; const control = resolveConnectionControl(state, historyPasswordLoading); - const sessionPrefix = formatSessionId(currentSessionId); const controlLabel = control.action === 'cancel' ? bilingual('取消连接', 'Cancel connection') : control.action === 'disconnect' - ? (sessionPrefix ? `${sessionPrefix} ${bilingual('断开', 'Disconnect')}` : bilingual('断开', 'Disconnect')) + ? bilingual('断开', 'Disconnect') : control.action === 'disconnecting' ? bilingual('断开中...', 'Disconnecting...') : bilingual('连接', 'Connect'); From a73b016def02729671f8a87d740296bd866169a9 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 04:06:30 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20=E7=A1=AE=E4=BF=9D=E5=9C=A8?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=A0=91=E6=97=B6=E5=B1=95=E5=BC=80=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E8=8A=82=E7=82=B9=E4=BB=A5=E6=B8=B2=E6=9F=93=E5=85=B6?= =?UTF-8?q?=E5=AD=90=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-tree.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index e00f49a..dbacae5 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -604,7 +604,17 @@ export class FileTree { for (const segment of segments) { if (token !== this.setCwdToken) return; - if (segment === current.path) continue; + 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; + } // If current is unavailable, we can't see its real children. // Create a synthetic child for the next segment so the walk can From ac7c9885d1feb00c2a6982ce04dc231150a22d4c Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 04:14:41 +0800 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=AE=A1=E7=90=86=E5=99=A8=E7=9A=84=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-manager.ts | 62 ++++++++++++++++++++++++++++++++++- src/backend/durable-object.ts | 17 ++++++---- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/frontend/src/file-manager.ts b/frontend/src/file-manager.ts index ab470f3..e3b2afa 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,11 @@ export class FileManager { } attach(url: string): void { + this.clearReconnectTimer(); + this.reconnectAttempts = 0; this.reset(); + this.wantConnection = true; + this.url = url; let target: URL; try { target = new URL(url, window.location.href); @@ -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,37 @@ 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…', + }); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + if (!this.wantConnection || !this.url) return; + try { + this.attach(this.url); + } catch { + this.scheduleReconnect(); + } + }, 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/src/backend/durable-object.ts b/src/backend/durable-object.ts index 457414d..35f4161 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -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: 0 as unknown as ReturnType, }; this.sftpAttachTokens.set(token, attach); this.sftpTokenByMainWebSocket.set(mainWebSocket, token); From 947870a0089a6840ef52b863b3079fcc30c0bfda Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 04:17:33 +0800 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=E7=AB=8B=E5=8D=B3=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E7=9B=AE=E5=BD=95=E8=8A=82=E7=82=B9=E4=BB=A5=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=97=A0=E9=9A=9C=E7=A2=8D=E5=AF=BC=E8=88=AA=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-tree.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index dbacae5..953764a 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -743,6 +743,10 @@ export class FileTree { this.updateTabindex(); if (node.type === 'directory' && !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. + void this.expandNode(node); } // Files/symlinks: select only, no right-side navigation // Unavailable directories: highlight but don't navigate (SFTP would From d0acfc2f540c9d7f138a2a4e1a75cab23ea8b849 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 04:22:16 +0800 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=E7=AB=8B=E5=8D=B3=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E6=A0=B9=E8=8A=82=E7=82=B9=E4=BB=A5=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E8=A7=86=E8=A7=89=E5=8F=8D=E9=A6=88=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=97=A0=E9=9A=9C=E7=A2=8D=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-tree.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index 953764a..5840639 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -223,6 +223,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); } @@ -418,9 +425,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'); } From 5689ed42392673fab01d18ef1994b74fd08a2b7b Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 04:26:32 +0800 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E7=AC=A6?= =?UTF-8?q?=E5=8F=B7=E9=93=BE=E6=8E=A5=E7=9A=84=E5=AF=BC=E8=88=AA=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=9B=AE=E5=BD=95=E6=A0=91=E7=9A=84=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-tree.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index 5840639..5b8fb8f 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -748,12 +748,16 @@ export class FileTree { this.highlightNode(node); this.focusedNode = node; this.updateTabindex(); - if (node.type === 'directory' && !node.unavailable) { + 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. - void this.expandNode(node); + // 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 // Unavailable directories: highlight but don't navigate (SFTP would From ff7cfb45df9f7228b8cda0f22645ae891fc93a43 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 04:32:45 +0800 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20=E7=AB=8B=E5=8D=B3=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E7=9B=AE=E6=A0=87=E7=9B=AE=E5=BD=95=E4=BB=A5=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E5=AD=90=E8=8A=82=E7=82=B9=E6=B8=B2=E6=9F=93=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=A0=E9=9A=9C=E7=A2=8D=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-tree.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index 5b8fb8f..d1da854 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -677,6 +677,16 @@ export class FileTree { 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; + } + this.highlightNode(current); this.focusedNode = current; this.updateTabindex(); From 4caf41a2edfb1ba51fd5d4651f54dbf82f8d2e9e Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 04:40:48 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E9=9D=A2=E6=9D=BF=E7=9A=84=E8=A1=8C=E9=AB=98?= =?UTF-8?q?=E6=AF=94=E4=BE=8B=E4=BB=A5=E4=BC=98=E5=8C=96=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/style.css b/frontend/src/style.css index 944686f..9533d90 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1099,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 { From 555d09a7f400027dd8f0dba53078cdcbd3087f77 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 05:29:17 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=E9=80=BB=E8=BE=91=E4=B8=AD=E7=9A=84=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E5=99=A8=EF=BC=8C=E7=A1=AE=E4=BF=9D=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=81=A2=E5=A4=8D=E6=97=B6=E7=8A=B6=E6=80=81=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/file-manager.ts | 15 ++++++++++++++- frontend/src/file-tree.ts | 17 +++-------------- src/backend/durable-object.ts | 10 +++++----- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/src/file-manager.ts b/frontend/src/file-manager.ts index e3b2afa..f62e770 100644 --- a/frontend/src/file-manager.ts +++ b/frontend/src/file-manager.ts @@ -247,7 +247,6 @@ export class FileManager { attach(url: string): void { this.clearReconnectTimer(); - this.reconnectAttempts = 0; this.reset(); this.wantConnection = true; this.url = url; @@ -274,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) => { @@ -1228,13 +1228,26 @@ export class FileManager { 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); } diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index d1da854..55fe72b 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -143,7 +143,7 @@ export class FileTree { * 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`. @@ -204,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). @@ -769,7 +757,8 @@ export class FileTree { 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). } diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index 35f4161..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; @@ -315,7 +315,7 @@ export class SSHSessionDO implements DurableObject { mainWebSocket, attachUrl, expiresAt: Number.MAX_SAFE_INTEGER, - timeout: 0 as unknown as ReturnType, + timeout: null, }; this.sftpAttachTokens.set(token, attach); this.sftpTokenByMainWebSocket.set(mainWebSocket, token); @@ -330,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); @@ -339,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); @@ -349,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);