6 files changed
+1257
-16
No files matched your search
+3
-1
@@ -7,4 +7,6 @@ coverage/
|
||||
.env*
|
||||
!.env.example
|
||||
.workbuddy/
|
||||
tests/
|
||||
tests/
|
||||
deliverables/
|
||||
frontend/dist-*
|
||||
@@ -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">☰</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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user