From b68c04019ab0fafc87378eb475557cbd5a92b24a Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 02:47:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E5=AF=B9=20expandNode?= =?UTF-8?q?=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); + } } }