fix: 提升错误信息的本地化支持,优化路径验证和目录列表响应处理
This commit is contained in:
1 parent
6a41590340
commit
1b9218f66a
2 files changed
+57
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
* <b>Invariant: any code path that replaces or clears this.root must increment treeEpoch.</b>
|
||||
*/
|
||||
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<void> {
|
||||
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 <body>. Move focus
|
||||
// onto the owning row first (no scrollIntoView, no scroll jump).
|
||||
this.focusNode(node);
|
||||
void this.expandNode(node);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user