Merge pull request #11 from cmliu/beta

Beta
This commit is contained in:
CMLiussss authored and GitHub committed 2026-07-31 01:03:15 +08:00
commit b4231e9c36
6 files changed
+1257 -16

No files matched your search

+3 -1
View File
@@ -7,4 +7,6 @@ coverage/
.env*
!.env.example
.workbuddy/
tests/
tests/
deliverables/
frontend/dist-*
+8
View File
@@ -391,6 +391,14 @@
</div>
<section id="file-manager-panel" class="file-manager" role="tabpanel" aria-labelledby="file-manager-tab" tabindex="0" hidden>
<aside id="file-tree" class="file-tree" aria-label="目录树" data-i18n-aria-label-zh="目录树" data-i18n-aria-label-en="Directory tree">
<div class="file-tree-header">
<span class="file-tree-title" data-i18n-zh="目录" data-i18n-en="Directories">目录</span>
<button id="file-tree-collapse" class="file-tree-collapse icon-button" type="button" aria-label="折叠侧栏" data-i18n-aria-label-zh="折叠侧栏" data-i18n-aria-label-en="Collapse sidebar" title="折叠侧栏" data-i18n-title-zh="折叠侧栏" data-i18n-title-en="Collapse sidebar">&#9776;</button>
</div>
<div id="file-tree-scroller" class="file-tree-scroller"></div>
<div class="file-tree-resize" aria-hidden="true"></div>
</aside>
<div class="file-toolbar">
<label class="file-path-field" for="file-manager-path">
<span class="sr-only" data-i18n-zh="当前路径" data-i18n-en="Current path">当前路径</span>
+143 -9
View File
@@ -46,6 +46,9 @@ export interface SFTPEntry {
group?: string;
}
/** Listener invoked whenever the FileManager's current working directory changes. */
export type CwdChangeListener = (cwd: string) => void;
interface LocalizedText {
zh: string;
en: string;
@@ -94,6 +97,11 @@ const DISCONNECTED: LocalizedText = {
en: 'Connect to SSH to manage files',
};
const DISCONNECTED_CHANNEL: LocalizedText = {
zh: '文件管理连接已断开',
en: 'File management connection lost',
};
function requiredElement<T extends Element>(root: ParentNode, id: string): T {
const node = root.querySelector<T>(`#${id}`);
if (!node) throw new Error(`Missing file manager element #${id}`);
@@ -204,6 +212,14 @@ export class FileManager {
private uploadConfirmationPending = false;
private messageQueue: Promise<void> = Promise.resolve();
private statusCopy: LocalizedText = DISCONNECTED;
/** Pending tree-view directory fetch promises, keyed by `tree-` prefixed requestId. */
private treeLists = new Map<string, {
resolve: (value: { path: string; entries: SFTPEntry[]; isTruncated: boolean }) => void;
reject: (error: Error) => void;
generation: number;
}>();
/** Registered cwd-change listeners, notified on every successful cwd mutation. */
private cwdListeners = new Set<CwdChangeListener>();
constructor(options: FileManagerOptions) {
this.elements = options.elements;
@@ -253,14 +269,15 @@ export class FileManager {
this.showError(this.localize({ zh: '文件服务连接失败。', en: 'The file service connection failed.' }));
}
});
socket.addEventListener('close', () => {
socket.addEventListener('close', (event: CloseEvent) => {
if (!this.isCurrent(socket, generation)) return;
this.socket = null;
this.ready = false;
this.abortTransfers(false);
this.pending.clear();
this.activeListRequest = null;
this.renderDisconnected();
this.rejectTreeLists(new Error('SFTP connection closed'));
this.renderDisconnected(true, this.describeClose(event));
});
}
@@ -278,6 +295,7 @@ export class FileManager {
this.uploadConfirmationPending = false;
this.pending.clear();
this.activeListRequest = null;
this.rejectTreeLists(new Error('SFTP session reset'));
this.abortTransfers(false);
if (socket) {
if (socket.readyState === WebSocket.OPEN) {
@@ -296,6 +314,7 @@ export class FileManager {
destroy(): void {
this.reset();
this.cwdListeners.clear();
this.bindings.abort();
}
@@ -329,11 +348,44 @@ export class FileManager {
}
setLanguage(): void {
this.setStatus(this.statusCopy);
this.updateStatusText(this.statusCopy);
this.renderEntries();
this.updateProgressLanguage();
}
/**
* Fetch directory entries for the tree view **without** disturbing the
* right-side panel (cwd, history, activeListRequest, or rendered entries).
* Uses a `tree-` prefixed requestId so handleListResult / handleServerError
* route the response to the tree channel instead of the normal list flow.
*/
fetchDirectoryEntries(path: string): Promise<{ path: string; entries: SFTPEntry[]; isTruncated: boolean }> {
if (!this.ready || !this.isSocketOpen()) {
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(this.localize({ zh: '路径无效。', en: 'Invalid path.' })));
}
const requestId = `tree-${this.generation}-${++this.requestSequence}`;
return new Promise((resolve, reject) => {
this.treeLists.set(requestId, { resolve, reject, generation: this.generation });
this.send({ type: 'sftp_list', requestId, path: normalized });
});
}
/**
* Register a listener that fires whenever the current working directory
* changes (after a successful list navigation or sftp_ready). Returns an
* unsubscribe function.
*/
onCwdChange(listener: CwdChangeListener): () => void {
this.cwdListeners.add(listener);
return () => { this.cwdListeners.delete(listener); };
}
async upload(file: File, destination?: string): Promise<void> {
if (!this.ready || this.hasTransfer() || this.uploadConfirmationPending) return;
if (file.size > MAX_SFTP_FILE_SIZE) {
@@ -550,6 +602,7 @@ export class FileManager {
try { cwd = normalizePath(typeof message.cwd === 'string' ? message.cwd : '/'); } catch { cwd = '/'; }
this.ready = true;
this.cwd = cwd;
this.emitCwdChange(cwd);
this.homePath = cwd;
this.history = [cwd];
this.historyIndex = 0;
@@ -578,6 +631,11 @@ export class FileManager {
private handleListResult(message: JsonRecord): void {
const requestId = typeof message.requestId === 'string' ? message.requestId : '';
// === Tree-view fetch: route to tree channel, never enter right-side rendering ===
if (requestId.startsWith('tree-')) {
this.resolveTreeList(message, requestId);
return;
}
const pending = this.pending.get(requestId);
if (!pending || pending.generation !== this.generation || pending.operation !== 'list') return;
this.pending.delete(requestId);
@@ -599,6 +657,7 @@ export class FileManager {
return left.name.localeCompare(right.name, this.isChinese() ? 'zh-CN' : 'en', { numeric: true, sensitivity: 'base' });
});
this.cwd = path;
this.emitCwdChange(path);
if (pending.historyMode === 'push' && this.history[this.historyIndex] !== path) {
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push(path);
@@ -907,6 +966,11 @@ export class FileManager {
private handleServerError(message: JsonRecord): void {
const requestId = typeof message.requestId === 'string' ? message.requestId : '';
// === Tree-view fetch error: route to tree channel, never enter right-side error handling ===
if (requestId.startsWith('tree-')) {
this.rejectTreeList(message, requestId);
return;
}
const pending = requestId ? this.pending.get(requestId) : undefined;
if (requestId && (!pending || pending.generation !== this.generation)) return;
if (requestId) this.pending.delete(requestId);
@@ -928,6 +992,63 @@ export class FileManager {
this.showError(serverMessage);
}
/** Resolve a pending tree-view fetch promise with parsed, sorted entries. */
private resolveTreeList(message: JsonRecord, requestId: string): void {
const entry = this.treeLists.get(requestId);
if (!entry || entry.generation !== this.generation) return;
this.treeLists.delete(requestId);
let path: string;
try { path = normalizePath(typeof message.path === 'string' ? message.path : ''); }
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(
this.localize({ zh: '目录列表数据异常。', en: 'The directory listing response was malformed.' }),
{ cause: 'invalid entries in tree list result' },
));
return;
}
const entries = message.entries
.map((raw) => this.parseEntry(raw))
.filter((item): item is SFTPEntry => item !== null);
entries.sort((left, right) => {
if (left.type === 'directory' && right.type !== 'directory') return -1;
if (left.type !== 'directory' && right.type === 'directory') return 1;
return left.name.localeCompare(right.name, this.isChinese() ? 'zh-CN' : 'en', { numeric: true, sensitivity: 'base' });
});
entry.resolve({ path, entries, isTruncated: message.isTruncated === true });
}
/** Reject a single pending tree-view fetch promise with the server error message. */
private rejectTreeList(message: JsonRecord, requestId: string): void {
const entry = this.treeLists.get(requestId);
if (!entry || entry.generation !== this.generation) return;
this.treeLists.delete(requestId);
const fallback = this.localize({ zh: '文件操作失败。', en: 'The file operation failed.' });
const serverMessage = typeof message.message === 'string' && message.message ? message.message : fallback;
entry.reject(new Error(serverMessage));
}
/** Reject all pending tree-view fetch promises (used on reset / close / destroy). */
private rejectTreeLists(error: Error): void {
this.treeLists.forEach((entry) => {
entry.reject(error);
});
this.treeLists.clear();
}
/** Notify all registered cwd-change listeners. Listener errors are swallowed. */
private emitCwdChange(cwd: string): void {
this.cwdListeners.forEach((listener) => {
try { listener(cwd); } catch { /* listener errors are non-fatal */ }
});
}
private handleTransferCancelled(message: JsonRecord, operation: 'upload' | 'download'): void {
const requestId = typeof message.requestId === 'string' ? message.requestId : '';
const state = operation === 'upload' ? this.uploadState : this.downloadState;
@@ -1032,7 +1153,7 @@ export class FileManager {
this.elements.delete.disabled = !idle || !selected;
}
private renderDisconnected(): void {
private renderDisconnected(channelLost = false, detail?: LocalizedText): void {
this.entries = [];
this.selectedIndex = -1;
this.elements.tableBody.replaceChildren();
@@ -1041,27 +1162,40 @@ export class FileManager {
this.elements.error.hidden = true;
this.elements.path.value = '/';
this.hideProgress();
this.setStatus(DISCONNECTED);
if (channelLost) this.setStatus(detail ?? DISCONNECTED_CHANNEL);
else this.updateStatusText(DISCONNECTED);
this.updateControls();
}
private describeClose(event: CloseEvent): LocalizedText | undefined {
if (event.code === 1000) return undefined;
const codeInfo = this.isChinese()
? `代码 ${event.code}${event.reason ? `:${event.reason}` : ''}`
: `code ${event.code}${event.reason ? `: ${event.reason}` : ''}`;
const base = DISCONNECTED_CHANNEL;
return { zh: `${base.zh}(${codeInfo})`, en: `${base.en} (${codeInfo})` };
}
private showError(message: string): void {
this.elements.loading.hidden = true;
this.elements.empty.hidden = true;
this.elements.error.textContent = message;
this.elements.error.hidden = false;
this.setStatus({ zh: message, en: message });
this.updateStatusText({ zh: message, en: message });
this.onError?.(message);
this.updateControls();
}
private setStatus(copy: LocalizedText): void {
this.updateStatusText(copy);
this.onStatus?.(this.localize(copy));
}
private updateStatusText(copy: LocalizedText): void {
this.statusCopy = copy;
this.elements.status.dataset.i18nZh = copy.zh;
this.elements.status.dataset.i18nEn = copy.en;
const text = this.localize(copy);
this.elements.status.textContent = text;
this.onStatus?.(text);
this.elements.status.textContent = this.localize(copy);
}
private showProgress(operation: 'upload' | 'download', name: string, percent: number): void {
+839
View File
@@ -0,0 +1,839 @@
import type { SFTPEntry } from './file-manager';
/** Result returned by FileManager.fetchDirectoryEntries. */
export interface TreeListResult {
path: string;
entries: SFTPEntry[];
isTruncated: boolean;
}
/** Options for constructing a FileTree instance. */
export interface FileTreeOptions {
/** The <aside id="file-tree"> container element. */
container: HTMLElement;
/** Callback to fetch directory entries (provided by FileManager.fetchDirectoryEntries). */
fetchEntries: (path: string) => Promise<TreeListResult>;
/** Returns the current UI language code. */
getLanguage: () => string;
/** Called when the user activates a folder node (navigates the right panel). */
onNavigate: (path: string) => void;
/** Optional error reporting callback. */
onError?: (message: string) => void;
/** Initial root path; defaults to "/". */
initialRoot?: string;
}
/** A node in the directory tree. */
interface TreeNode {
/** Absolute path, e.g. "/etc/nginx"; root is "/". */
path: string;
/** Display name; root is "/". */
name: string;
/** Entry type (symlink treated as non-expandable, same as file). */
type: 'directory' | 'file' | 'symlink' | 'other';
/** Parent node, null for root. */
parent: TreeNode | null;
/**
* Children:
* - null: not yet loaded (collapsed)
* - []: loaded and empty
* - [...]: loaded child nodes
*/
children: TreeNode[] | null;
/** State machine driving UI rendering. */
state: 'collapsed' | 'loading' | 'expanded' | 'error';
/** Backend truncation flag. */
isTruncated: boolean;
/** Load token: incremented on each fetch to cancel stale results. */
loadToken: number;
/** Cached DOM row element (.file-tree-row), null if not yet rendered. */
element: HTMLElement | null;
/** Depth in the tree (root = 0). */
depth: number;
}
interface LocalizedText {
zh: string;
en: string;
}
const EMPTY_TEXT: LocalizedText = { zh: '— 空 —', en: '— Empty —' };
const TRUNCATED_TEXT: LocalizedText = { zh: '(已截断)', en: '(truncated)' };
const RETRY_TEXT: LocalizedText = { zh: '重试', en: 'Retry' };
const WIDTH_STORAGE_KEY = 'cf-webssh.file-tree.width';
const COLLAPSED_STORAGE_KEY = 'cf-webssh.file-tree.collapsed';
const DEFAULT_WIDTH = 240;
const MIN_WIDTH = 160;
const MAX_WIDTH = 480;
/**
* Split an absolute path into ancestor segments, root-first.
* e.g. "/var/log/nginx" -> ["/", "/var", "/var/log", "/var/log/nginx"]
*/
function splitPathSegments(path: string): string[] {
if (path === '/') return ['/'];
const segments: string[] = ['/'];
const parts = path.split('/').filter(Boolean);
let accumulated = '';
for (const part of parts) {
accumulated += `/${part}`;
segments.push(accumulated);
}
return segments;
}
/** Check whether `child` is the same as or a descendant of `parent`. */
function isDescendantPath(parent: string, child: string): boolean {
if (parent === child) return true;
if (parent === '/') return child.startsWith('/');
return child.startsWith(parent + '/');
}
/**
* FileTree implements a lazy-loading directory tree view using pure DOM.
*
* Features:
* - Lazy expand/collapse with loadToken race-condition cancellation
* - setCwd path expansion with setCwdToken cancellation
* - ARIA tree / roving tabindex keyboard navigation
* - Width drag-to-resize with localStorage persistence
* - Collapsible sidebar with localStorage persistence
* - Full i18n support (zh/en)
*/
export class FileTree {
private readonly container: HTMLElement;
private readonly fetchEntries: FileTreeOptions['fetchEntries'];
private readonly getLanguage: () => string;
private readonly onNavigate: (path: string) => void;
private readonly onError?: (message: string) => void;
private readonly bindings = new AbortController();
private readonly scroller: HTMLElement;
private readonly collapseButton: HTMLButtonElement;
private readonly resizeHandle: HTMLElement;
private root: TreeNode | null = null;
private selectedPath: string | null = null;
private focusedNode: TreeNode | null = null;
private rootPath = '/';
private ready = false;
private collapsed = false;
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;
this.fetchEntries = options.fetchEntries;
this.getLanguage = options.getLanguage;
this.onNavigate = options.onNavigate;
this.onError = options.onError;
this.rootPath = options.initialRoot ?? '/';
// Query sub-elements from the container (provided by index.html)
const scroller = this.container.querySelector<HTMLElement>('.file-tree-scroller');
const collapseBtn = this.container.querySelector<HTMLButtonElement>('.file-tree-collapse');
const resize = this.container.querySelector<HTMLElement>('.file-tree-resize');
if (!scroller || !collapseBtn || !resize) {
throw new Error('FileTree: container must contain .file-tree-scroller, .file-tree-collapse, .file-tree-resize');
}
this.scroller = scroller;
this.collapseButton = collapseBtn;
this.resizeHandle = resize;
this.container.setAttribute('role', 'tree');
// Load persisted state
this.loadWidth();
this.loadCollapsed();
// Bind events
const signal = this.bindings.signal;
this.scroller.addEventListener('keydown', (event) => this.handleKeydown(event), { signal });
this.scroller.addEventListener('click', (event) => this.handleClick(event), { signal });
this.collapseButton.addEventListener('click', () => this.toggleCollapsed(), { signal });
this.initResize(signal);
}
// ── Public API ──────────────────────────────────────────────────────
/** Mark the tree as ready (SFTP channel open) or not (disconnected). */
setReady(ready: boolean): void {
this.ready = ready;
if (!ready) {
this.treeEpoch++;
this.root = null;
this.selectedPath = null;
this.focusedNode = null;
this.scroller.replaceChildren();
return;
}
// Initialize with just the root node (collapsed)
this.root = this.createRootNode(this.rootPath);
this.focusedNode = this.root;
this.selectedPath = null;
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).
*/
setCwd(cwd: string): void {
this.selectedPath = cwd;
this.setCwdToken++;
const token = this.setCwdToken;
void this.expandToPath(cwd, token);
}
/** Re-render i18n text after a language switch. */
setLanguage(): void {
if (this.root) {
this.rerenderVisible(this.root);
}
}
/** Tear down: abort listeners, clear DOM. */
destroy(): void {
this.bindings.abort();
this.treeEpoch++;
this.root = null;
this.selectedPath = null;
this.focusedNode = null;
this.ready = false;
this.scroller.replaceChildren();
}
// ── Node creation ───────────────────────────────────────────────────
private createRootNode(path: string): TreeNode {
return {
path,
name: path === '/' ? '/' : path.slice(path.lastIndexOf('/') + 1),
type: 'directory',
parent: null,
children: null,
state: 'collapsed',
isTruncated: false,
loadToken: 0,
element: null,
depth: 0,
};
}
private createTreeNode(entry: SFTPEntry, parent: TreeNode): TreeNode {
const childPath = parent.path === '/'
? `/${entry.name}`
: `${parent.path}/${entry.name}`;
return {
path: childPath,
name: entry.name,
type: entry.type,
parent,
children: null,
state: 'collapsed',
isTruncated: false,
loadToken: 0,
element: null,
depth: parent.depth + 1,
};
}
/** Whether a node can be expanded (only directories). */
private isExpandable(node: TreeNode): boolean {
return node.type === 'directory';
}
// ── Rendering ───────────────────────────────────────────────────────
/** Render the root node into the scroller. */
private renderRoot(): void {
if (!this.root) return;
this.scroller.replaceChildren();
const el = this.renderNode(this.root);
this.scroller.append(el);
this.updateTabindex();
}
/**
* Create (or refresh) the DOM row for a node.
* If the node already has an element, its inner content is updated in-place.
*/
private renderNode(node: TreeNode): HTMLElement {
if (node.element) {
this.refreshRowContent(node);
return node.element;
}
const row = document.createElement('div');
row.className = 'file-tree-row';
row.setAttribute('role', 'treeitem');
row.style.setProperty('--file-tree-depth', String(node.depth));
row.dataset.path = node.path;
row.tabIndex = -1;
node.element = row;
this.refreshRowContent(node);
return row;
}
/** Update the inner content and state classes of a node's existing row. */
private refreshRowContent(node: TreeNode): void {
const row = node.element;
if (!row) return;
// Reset classes, keeping is-selected if applicable
const isSelected = this.selectedPath === node.path;
row.className = 'file-tree-row';
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');
row.style.setProperty('--file-tree-depth', String(node.depth));
row.dataset.path = node.path;
row.replaceChildren();
const expandable = this.isExpandable(node);
// Toggle / spinner / 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 (expandable) {
const toggle = document.createElement('span');
toggle.className = 'file-tree-toggle';
toggle.setAttribute('aria-hidden', 'true');
toggle.textContent = node.state === 'expanded' ? '\u25BE' : '\u25B8';
row.append(toggle);
} else {
const spacer = document.createElement('span');
spacer.className = 'file-tree-toggle';
spacer.setAttribute('aria-hidden', 'true');
row.append(spacer);
}
// Icon
const icon = document.createElement('span');
icon.className = `file-tree-icon ${node.type}`;
icon.setAttribute('aria-hidden', 'true');
icon.textContent = node.type === 'directory' ? '\u25A0' : node.type === 'symlink' ? '\u2197' : '\u25A1';
row.append(icon);
// Name
const name = document.createElement('span');
name.className = 'file-tree-name';
name.textContent = node.name;
row.append(name);
// Retry button for error state
if (node.state === 'error') {
const retry = document.createElement('button');
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);
}
// ARIA expanded
if (expandable) {
row.setAttribute('aria-expanded', String(node.state === 'expanded'));
} else {
row.removeAttribute('aria-expanded');
}
}
/** Create a non-interactive status row (empty / truncated indicator). */
private createStatusRow(copy: LocalizedText, depth: number): HTMLElement {
const row = document.createElement('div');
row.className = 'file-tree-status';
row.setAttribute('aria-hidden', 'true');
row.style.setProperty('--file-tree-depth', String(depth));
row.dataset.i18nZh = copy.zh;
row.dataset.i18nEn = copy.en;
row.textContent = this.localize(copy);
return row;
}
/** Render child rows (and status indicators) after an expanded node's row. */
private renderChildren(node: TreeNode): void {
const row = node.element;
if (!row || !node.children) return;
// Remove any existing descendant rows first
this.removeDescendantRows(node);
const fragment = document.createDocumentFragment();
for (const child of node.children) {
const childRow = this.renderNode(child);
fragment.append(childRow);
}
// Empty directory indicator
if (node.children.length === 0) {
fragment.append(this.createStatusRow(EMPTY_TEXT, node.depth + 1));
}
// Truncated sentinel
if (node.isTruncated) {
fragment.append(this.createStatusRow(TRUNCATED_TEXT, node.depth + 1));
}
row.after(fragment);
this.updateTabindex();
}
/** Remove all descendant rows (and status rows) of `node` from the DOM. */
private removeDescendantRows(node: TreeNode): void {
const row = node.element;
if (!row) return;
let next = row.nextElementSibling as HTMLElement | null;
while (next) {
const depthStr = next.style.getPropertyValue('--file-tree-depth');
const depth = depthStr ? Number(depthStr) : -1;
// Stop when we reach a sibling at the same or shallower depth
if (depth >= 0 && depth <= node.depth) break;
const toRemove = next;
next = next.nextElementSibling as HTMLElement | null;
toRemove.remove();
}
}
// ── Expand / Collapse ───────────────────────────────────────────────
/**
* Lazily load and expand a node's children.
*
* 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';
this.refreshRowContent(node);
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;
// 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));
node.isTruncated = result.isTruncated;
node.state = 'expanded';
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);
this.refreshRowContent(node);
const message = error instanceof Error ? error.message : String(error);
this.onError?.(message);
}
}
/** Collapse a node: cancel pending fetch, discard children, remove DOM. */
private collapseNode(node: TreeNode): void {
if (!this.isExpandable(node) || node.state === 'collapsed') return;
node.loadToken++; // cancel any pending expand
node.state = 'collapsed';
node.children = null;
node.isTruncated = false;
this.removeDescendantRows(node);
this.refreshRowContent(node);
this.updateTabindex();
}
// ── setCwd path expansion (Risk 2: setCwdToken cancellation) ────────
/**
* Walk from root down to `cwd`, expanding ancestors along the way.
* The `token` parameter cancels the operation if setCwd is called again.
*/
private async expandToPath(cwd: string, token: number): Promise<void> {
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);
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
}
// Find the child matching this path segment
const child = current.children?.find((c) => c.path === segment);
if (!child) break; // path segment not found; highlight deepest ancestor
current = child;
}
if (token !== this.setCwdToken) return; // cancelled
// Highlight and scroll to the target node
this.highlightNode(current);
this.focusedNode = current;
this.updateTabindex();
current.element?.scrollIntoView({ block: 'nearest' });
}
/** Set is-selected on the target node, removing it from all others. */
private highlightNode(node: TreeNode): void {
this.selectedPath = node.path;
const rows = this.scroller.querySelectorAll('.file-tree-row.is-selected');
rows.forEach((row) => row.classList.remove('is-selected'));
if (node.element) {
node.element.classList.add('is-selected');
}
}
// ── Keyboard navigation (ARIA tree + roving tabindex) ───────────────
/** Depth-first list of all visible (rendered) nodes. */
private getVisibleNodes(): TreeNode[] {
const result: TreeNode[] = [];
const walk = (node: TreeNode): void => {
result.push(node);
if (node.state === 'expanded' && node.children) {
for (const child of node.children) {
walk(child);
}
}
};
if (this.root) walk(this.root);
return result;
}
/** Find a TreeNode by path (searches the entire loaded tree). */
private findNodeByPath(path: string): TreeNode | null {
if (!this.root) return null;
const walk = (node: TreeNode): TreeNode | null => {
if (node.path === path) return node;
if (node.children) {
for (const child of node.children) {
const found = walk(child);
if (found) return found;
}
}
return null;
};
return walk(this.root);
}
/** Update roving tabindex: only the focused node gets tabIndex=0. */
private updateTabindex(): void {
const rows = this.scroller.querySelectorAll<HTMLElement>('.file-tree-row');
rows.forEach((row) => { row.tabIndex = -1; });
const focusTarget = this.focusedNode ?? this.root;
if (focusTarget?.element) {
focusTarget.element.tabIndex = 0;
}
}
/** Move focus to a node and update roving tabindex. */
private focusNode(node: TreeNode): void {
this.focusedNode = node;
this.updateTabindex();
node.element?.focus();
}
/** Activate a node (Enter key or click on folder): select + navigate. */
private activateNode(node: TreeNode): void {
this.highlightNode(node);
this.focusedNode = node;
this.updateTabindex();
if (node.type === 'directory') {
this.onNavigate(node.path);
}
// Files/symlinks: select only, no right-side navigation
}
private handleKeydown(event: KeyboardEvent): void {
if (!this.focusedNode) return;
const node = this.focusedNode;
const visible = this.getVisibleNodes();
const index = visible.indexOf(node);
if (index < 0) return;
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const next = visible[index + 1];
if (next) this.focusNode(next);
break;
}
case 'ArrowUp': {
event.preventDefault();
const prev = visible[index - 1];
if (prev) this.focusNode(prev);
break;
}
case 'ArrowRight': {
event.preventDefault();
if (this.isExpandable(node)) {
if (node.state === 'collapsed' || node.state === 'error') {
void this.expandNode(node);
} else if (node.state === 'expanded' && node.children && node.children.length > 0) {
this.focusNode(node.children[0]);
}
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
if (this.isExpandable(node) && node.state === 'expanded') {
this.collapseNode(node);
} else if (node.parent) {
this.focusNode(node.parent);
}
break;
}
case 'Enter': {
event.preventDefault();
this.activateNode(node);
break;
}
case ' ': {
event.preventDefault();
if (this.isExpandable(node)) {
if (node.state === 'expanded') {
this.collapseNode(node);
} else {
void this.expandNode(node);
}
}
break;
}
case 'Home': {
event.preventDefault();
if (visible[0]) this.focusNode(visible[0]);
break;
}
case 'End': {
event.preventDefault();
const last = visible[visible.length - 1];
if (last) this.focusNode(last);
break;
}
}
}
// ── Click handling ──────────────────────────────────────────────────
private handleClick(event: MouseEvent): void {
const row = event.target instanceof Element
? event.target.closest<HTMLElement>('.file-tree-row')
: null;
if (!row) return;
const path = row.dataset.path;
if (!path) return;
const node = this.findNodeByPath(path);
if (!node) return;
// Toggle arrow click → expand/collapse
if (event.target instanceof Element && event.target.closest('.file-tree-toggle')) {
if (this.isExpandable(node)) {
if (node.state === 'expanded') {
this.collapseNode(node);
} else {
void this.expandNode(node);
}
}
return;
}
// 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;
}
// Row click → focus + activate
this.focusNode(node);
this.activateNode(node);
}
// ── Width persistence + drag ────────────────────────────────────────
private get managerElement(): HTMLElement | null {
return this.container.parentElement;
}
private loadWidth(): void {
let width = DEFAULT_WIDTH;
try {
const stored = localStorage.getItem(WIDTH_STORAGE_KEY);
if (stored) {
const parsed = Number(stored);
if (Number.isFinite(parsed) && parsed >= MIN_WIDTH && parsed <= MAX_WIDTH) {
width = parsed;
}
}
} catch { /* localStorage unavailable */ }
this.width = width;
this.applyWidth();
}
private applyWidth(): void {
this.managerElement?.style.setProperty('--file-tree-width', `${this.width}px`);
}
private saveWidth(width: number): void {
this.width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(width)));
this.applyWidth();
try { localStorage.setItem(WIDTH_STORAGE_KEY, String(this.width)); } catch { /* ignore */ }
}
private initResize(signal: AbortSignal): void {
let dragging = false;
let startX = 0;
let startWidth = 0;
this.resizeHandle.addEventListener('mousedown', (event: MouseEvent) => {
dragging = true;
startX = event.clientX;
startWidth = this.width;
event.preventDefault();
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, { signal });
document.addEventListener('mousemove', (event: MouseEvent) => {
if (!dragging) return;
const delta = event.clientX - startX;
this.saveWidth(startWidth + delta);
}, { signal });
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, { signal });
}
// ── Collapsed sidebar ───────────────────────────────────────────────
private loadCollapsed(): void {
let stored: string | null = null;
try {
stored = localStorage.getItem(COLLAPSED_STORAGE_KEY);
} catch { /* localStorage unavailable */ }
if (stored === null) {
// No saved preference: default to collapsed on narrow screens.
this.collapsed = typeof window.matchMedia === 'function'
&& window.matchMedia('(max-width: 760px)').matches;
} else {
this.collapsed = stored === 'true';
}
if (this.collapsed) this.container.classList.add('is-collapsed');
}
private toggleCollapsed(): void {
this.collapsed = !this.collapsed;
this.container.classList.toggle('is-collapsed', this.collapsed);
try { localStorage.setItem(COLLAPSED_STORAGE_KEY, String(this.collapsed)); } catch { /* ignore */ }
}
// ── i18n helpers ────────────────────────────────────────────────────
/** Re-render all visible rows and status text after a language switch. */
private rerenderVisible(node: TreeNode): void {
this.refreshRowContent(node);
// Update status rows immediately following this node
const row = node.element;
if (row) {
let next = row.nextElementSibling as HTMLElement | null;
while (next && next.classList.contains('file-tree-status')) {
const zh = next.dataset.i18nZh;
const en = next.dataset.i18nEn;
if (zh && en) next.textContent = this.isChinese() ? zh : en;
next = next.nextElementSibling as HTMLElement | null;
}
}
if (node.state === 'expanded' && node.children) {
for (const child of node.children) {
this.rerenderVisible(child);
}
}
}
private localize(copy: LocalizedText): string {
return this.isChinese() ? copy.zh : copy.en;
}
private isChinese(): boolean {
return this.getLanguage().toLowerCase().startsWith('zh');
}
}
+20
View File
@@ -7,6 +7,7 @@ import { decryptPasswordResult, encryptPassword, isEncryptedPassword } from './p
import { resolveConnectionControl, resolveConnectionPanel } from './ui-state';
import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key';
import { FileManager, collectFileManagerElements } from './file-manager';
import { FileTree } from './file-tree';
import { ProcessManager, collectProcessManagerElements } from './process-manager';
import { resetTerminalForConnection } from './terminal-session';
import './style.css';
@@ -293,6 +294,7 @@ const ui = {
eventMessage: element<HTMLElement>('event-message'),
fileManagerTab: element<HTMLButtonElement>('file-manager-tab'),
fileManagerPanel: element<HTMLElement>('file-manager-panel'),
fileTree: element<HTMLElement>('file-tree'),
processManagerTab: element<HTMLButtonElement>('process-manager-tab'),
processManagerPanel: element<HTMLElement>('process-manager-panel'),
eventToggle: element<HTMLButtonElement>('event-toggle'),
@@ -362,6 +364,7 @@ function applyLanguage(language: Language, persist = false): void {
}
}
if (fileManager) fileManager.setLanguage();
if (fileTree) fileTree.setLanguage();
if (processManager) processManager.setLanguage();
if (persist) {
@@ -402,6 +405,7 @@ const latestHistoryMutation = new Map<string, number>();
const panelMedia = window.matchMedia('(max-width: 760px)');
let panelOpen = !panelMedia.matches;
let fileManager: FileManager;
let fileTree: FileTree;
let processManager: ProcessManager;
const terminal = new Terminal({
@@ -425,7 +429,17 @@ fileManager = new FileManager({
elements: collectFileManagerElements(),
getLanguage: () => currentLanguage,
onError: (message) => event(message, 'sftp', true),
onStatus: (message) => event(message, 'sftp'),
});
fileTree = new FileTree({
container: ui.fileTree,
fetchEntries: (path) => fileManager.fetchDirectoryEntries(path),
getLanguage: () => currentLanguage,
onNavigate: (path) => fileManager.navigate(path),
onError: (message) => event(message, 'sftp', true),
initialRoot: '/',
});
fileManager.onCwdChange((cwd) => fileTree.setCwd(cwd));
processManager = new ProcessManager({
elements: collectProcessManagerElements(),
getLanguage: () => currentLanguage,
@@ -1312,6 +1326,7 @@ function handleServerMessage(message: ServerMessage): void {
event(bilingual('无法打开文件管理连接。', 'Could not open the file-management connection.'), 'sftp', true);
return;
}
fileTree.setReady(true);
event(bilingual('文件管理通道已可用。', 'File management channel is available.'), 'sftp');
return;
}
@@ -1449,6 +1464,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin
currentRememberedFingerprint = '';
stopTimers();
fileManager.reset();
fileTree?.setReady(false);
processManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
@@ -1592,6 +1608,7 @@ async function connect(): Promise<void> {
currentRememberedFingerprint = '';
stopTimers();
fileManager.reset();
fileTree?.setReady(false);
processManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
@@ -1636,6 +1653,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
socket = null;
stopTimers();
fileManager.reset();
fileTree?.setReady(false);
processManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
@@ -1923,6 +1941,8 @@ terminal.onData(sendTerminalData);
new ResizeObserver(() => fitTerminal(true)).observe(ui.terminalStage);
window.addEventListener('beforeunload', () => {
fileManager.reset();
fileTree?.setReady(false);
fileTree?.destroy();
processManager.reset();
socket?.close(1000, 'Page closed');
});
+244 -6
View File
@@ -1340,11 +1340,230 @@ button:disabled {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: var(--file-tree-width, 240px) minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) 25px;
grid-template-areas:
"toolbar toolbar"
"tree table"
"status status";
overflow: hidden;
background: var(--surface);
}
.file-manager:has(> .file-tree.is-collapsed) {
grid-template-columns: 24px minmax(0, 1fr);
}
.file-manager:has(> .file-tree.is-collapsed) > .file-tree {
overflow: hidden;
}
.file-manager:has(> .file-tree.is-collapsed) > .file-tree .file-tree-scroller,
.file-manager:has(> .file-tree.is-collapsed) > .file-tree .file-tree-resize {
display: none;
}
.file-manager:has(> .file-tree.is-collapsed) > .file-tree .file-tree-title {
display: none;
}
.file-manager:has(> .file-tree.is-collapsed) > .file-tree .file-tree-header {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.file-toolbar { grid-area: toolbar; }
.file-tree { grid-area: tree; }
.file-table-wrap { grid-area: table; }
.file-statusbar { grid-area: status; }
/* ── File tree sidebar ─────────────────────────────────────────── */
.file-tree {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--border-soft);
background: var(--surface);
position: relative;
}
.file-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 0 6px;
height: 25px;
flex: 0 0 auto;
border-bottom: 1px solid var(--border-soft);
background: var(--panel);
color: var(--muted);
font-family: var(--sans);
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.file-tree-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-tree-collapse {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
padding: 0;
border: 0;
border-radius: 3px;
background: transparent;
color: var(--muted);
font-size: 11px;
cursor: pointer;
line-height: 1;
}
.file-tree-collapse:hover,
.file-tree-collapse:focus-visible {
background: var(--surface-3);
color: var(--text);
}
.file-tree-scroller {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
padding: 4px 0;
}
.file-tree-row {
display: flex;
align-items: center;
gap: 4px;
min-height: 18px;
padding: 1px 8px;
padding-left: calc(8px + var(--file-tree-depth, 0) * 14px);
font-family: var(--mono);
font-size: 10px;
color: var(--text);
cursor: pointer;
white-space: nowrap;
user-select: none;
outline: 0;
}
.file-tree-row:hover {
background: var(--surface-3);
}
.file-tree-row:focus-visible {
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--accent-dim);
}
.file-tree-row.is-selected {
background: var(--accent-dim);
color: var(--accent);
}
.file-tree-toggle {
width: 12px;
flex: 0 0 auto;
text-align: center;
color: var(--muted);
font-size: 8px;
line-height: 1;
}
.file-tree-icon {
width: 14px;
flex: 0 0 auto;
font-size: 10px;
text-align: center;
}
.file-tree-icon.directory {
color: var(--warning);
}
.file-tree-icon.file,
.file-tree-icon.symlink,
.file-tree-icon.other {
color: var(--muted);
}
.file-tree-name {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
}
.file-tree-row.is-loading .file-tree-spinner {
width: 10px;
height: 10px;
border: 1.5px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: file-spin 750ms linear infinite;
flex: 0 0 auto;
}
.file-tree-row.is-error {
color: var(--danger);
}
.file-tree-retry {
flex: 0 0 auto;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--accent);
font-family: var(--sans);
font-size: 8px;
cursor: pointer;
text-decoration: underline;
}
.file-tree-retry:hover {
color: var(--accent-strong);
}
.file-tree-status {
display: flex;
align-items: center;
padding: 1px 8px;
padding-left: calc(8px + var(--file-tree-depth, 0) * 14px + 16px);
min-height: 18px;
font-family: var(--mono);
font-size: 9px;
color: var(--muted-2);
font-style: italic;
white-space: nowrap;
user-select: none;
}
.file-tree-resize {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 2;
}
.file-tree-resize:hover,
.file-tree-resize:active {
background: var(--accent-dim);
}
.process-manager {
display: grid;
min-width: 0;
@@ -1580,7 +1799,7 @@ button:disabled {
.file-table {
width: 100%;
min-width: 740px;
min-width: 240px;
border-spacing: 0;
table-layout: fixed;
color: var(--text);
@@ -1665,7 +1884,7 @@ button:disabled {
.file-manager-state {
position: absolute;
inset: 25px 0 0;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -2123,12 +2342,31 @@ button:disabled {
padding: 3px 5px;
}
.file-actions {
width: 100%;
/* Mobile: keep the tree column but default it to a 24px collapsed strip
(see JS loadCollapsed()). Use min() so a width dragged wide on desktop
won't fill the whole phone screen. */
.file-manager {
grid-template-columns: min(var(--file-tree-width, 200px), 45vw) minmax(0, 1fr);
grid-template-areas:
"toolbar toolbar"
"tree table"
"status status";
}
.file-table {
min-width: 560px;
/* Restate the collapsed column width inside the media query: the rule at
L1353 (outside the query) is overridden by the .file-manager rule above
due to source order, so the 24px collapsed width must be repeated here. */
.file-manager:has(> .file-tree.is-collapsed) {
grid-template-columns: 24px minmax(0, 1fr);
}
/* Touch screens: dragging the resize handle is error-prone, hide it. */
.file-tree-resize {
display: none;
}
.file-actions {
width: 100%;
}
.process-table {