diff --git a/frontend/src/file-manager.ts b/frontend/src/file-manager.ts index aca84dd..ab470f3 100644 --- a/frontend/src/file-manager.ts +++ b/frontend/src/file-manager.ts @@ -361,13 +361,13 @@ export class FileManager { */ fetchDirectoryEntries(path: string): Promise<{ path: string; entries: SFTPEntry[]; isTruncated: boolean }> { if (!this.ready || !this.isSocketOpen()) { - return Promise.reject(new Error('SFTP not ready')); + return Promise.reject(new Error(this.localize({ zh: 'SFTP 尚未就绪。', en: 'SFTP is not ready.' }))); } let normalized: string; try { normalized = normalizePath(path); } catch { - return Promise.reject(new Error('Invalid path')); + return Promise.reject(new Error(this.localize({ zh: '路径无效。', en: 'Invalid path.' }))); } const requestId = `tree-${this.generation}-${++this.requestSequence}`; return new Promise((resolve, reject) => { @@ -999,9 +999,18 @@ export class FileManager { this.treeLists.delete(requestId); let path: string; try { path = normalizePath(typeof message.path === 'string' ? message.path : ''); } - catch { entry.reject(new Error('Invalid path in tree list result')); return; } + catch { + entry.reject(new Error( + this.localize({ zh: '目录列表数据异常。', en: 'The directory listing response was malformed.' }), + { cause: 'invalid path in tree list result' }, + )); + return; + } if (!Array.isArray(message.entries)) { - entry.reject(new Error('Invalid entries in tree list result')); + entry.reject(new Error( + this.localize({ zh: '目录列表数据异常。', en: 'The directory listing response was malformed.' }), + { cause: 'invalid entries in tree list result' }, + )); return; } const entries = message.entries diff --git a/frontend/src/file-tree.ts b/frontend/src/file-tree.ts index 9a78e95..e6e3bed 100644 --- a/frontend/src/file-tree.ts +++ b/frontend/src/file-tree.ts @@ -57,11 +57,9 @@ interface LocalizedText { en: string; } -const TREE_TITLE: LocalizedText = { zh: '目录', en: 'Directories' }; const EMPTY_TEXT: LocalizedText = { zh: '— 空 —', en: '— Empty —' }; const TRUNCATED_TEXT: LocalizedText = { zh: '(已截断)', en: '(truncated)' }; const RETRY_TEXT: LocalizedText = { zh: '重试', en: 'Retry' }; -const COLLAPSE_TEXT: LocalizedText = { zh: '折叠侧栏', en: 'Collapse sidebar' }; const WIDTH_STORAGE_KEY = 'cf-webssh.file-tree.width'; const COLLAPSED_STORAGE_KEY = 'cf-webssh.file-tree.collapsed'; @@ -124,6 +122,17 @@ export class FileTree { private width = DEFAULT_WIDTH; /** Token to cancel a previous in-progress setCwd expansion (Risk 2). */ private setCwdToken = 0; + /** + * 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()) + * 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`. + * Invariant: any code path that replaces or clears this.root must increment treeEpoch. + */ + private treeEpoch = 0; constructor(options: FileTreeOptions) { this.container = options.container; @@ -164,6 +173,7 @@ export class FileTree { setReady(ready: boolean): void { this.ready = ready; if (!ready) { + this.treeEpoch++; this.root = null; this.selectedPath = null; this.focusedNode = null; @@ -181,6 +191,7 @@ export class FileTree { setRootPath(path: string): void { this.rootPath = path; if (this.ready) { + this.treeEpoch++; this.root = this.createRootNode(path); this.focusedNode = this.root; this.selectedPath = null; @@ -209,6 +220,7 @@ export class FileTree { /** Tear down: abort listeners, clear DOM. */ destroy(): void { this.bindings.abort(); + this.treeEpoch++; this.root = null; this.selectedPath = null; this.focusedNode = null; @@ -343,6 +355,15 @@ export class FileTree { retry.type = 'button'; retry.className = 'file-tree-retry'; retry.textContent = this.localize(RETRY_TEXT); + // Keep retry out of the Tab sequence. The tree uses a roving-tabindex + // model (only the focused row has tabIndex=0); a natively tabbable + // control inside role="treeitem" would break ARIA tree navigation. + retry.tabIndex = -1; + // Give assistive tech a node-scoped label instead of context-free "Retry". + retry.setAttribute('aria-label', this.localize({ + zh: `重试加载 ${node.name}`, + en: `Retry loading ${node.name}`, + })); row.append(retry); } @@ -411,11 +432,17 @@ export class FileTree { /** * Lazily load and expand a node's children. - * Uses loadToken to discard results from a cancelled (superseded) fetch. + * + * Two independent cancellation layers: + * - `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; + // Capture the tree generation this call belongs to. + const epoch = this.treeEpoch; + node.loadToken++; const token = node.loadToken; node.state = 'loading'; @@ -423,7 +450,11 @@ export class FileTree { try { const result = await this.fetchEntries(node.path); - // Race-condition guard: if loadToken changed, this fetch was superseded + // 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; + // Per-node race-condition guard: if loadToken changed, this was superseded. if (token !== node.loadToken) return; node.children = result.entries.map((entry) => this.createTreeNode(entry, node)); @@ -432,7 +463,12 @@ export class FileTree { this.refreshRowContent(node); this.renderChildren(node); } 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; + node.state = 'error'; node.children = null; this.removeDescendantRows(node); @@ -671,6 +707,10 @@ export class FileTree { // Retry button click → re-expand if (event.target instanceof Element && event.target.closest('.file-tree-retry')) { + // expandNode calls refreshRowContent (destroys this button); and the + // button is tabIndex=-1 so focus would fall back to . Move focus + // onto the owning row first (no scrollIntoView, no scroll jump). + this.focusNode(node); void this.expandNode(node); return; }