Files
EdgeSSH/frontend/src/file-tree.ts
T

989 lines
35 KiB
TypeScript

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;
/**
* True if the node is on the path to cwd but its SFTP list failed
* (e.g. Permission denied). The node is rendered as a "locked" leaf —
* still visible to preserve the full path, but not expandable.
*/
unavailable: boolean;
}
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;
/**
* Map tracking in-flight expandNode promises keyed by node.
*
* When a second expandToPath call overlaps with an on-going expandNode
* fetch on the same node (e.g. sftp_ready and sftp_list_result both
* emitCwdChange), the second call retrieves the in-flight promise and
* awaits it instead of bailing out. This prevents the race condition
* where the second expandToPath breaks prematurely because the node
* was still 'loading'.
*/
private inflightExpands = new Map<TreeNode, Promise<void>>();
/**
* 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++;
// Kick off root expansion immediately for visual feedback so aria-expanded
// changes to "true" (or shows a spinner) without waiting for the async
// SFTP fetch. silent=true keeps the unavailable-path compatible with
// expandToPath.
if (this.root && this.root.state === 'collapsed') {
void this.expandNode(this.root, true);
}
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,
unavailable: false,
};
}
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,
unavailable: false,
};
}
/**
* Create a synthetic "potentially available" child for the next path
* segment when the parent's list failed. The synthetic node starts
* without the unavailable flag — it will be marked unavailable on the
* next fetch attempt if that one also fails.
*/
private createUnavailableChild(path: string, parent: TreeNode): TreeNode {
const name = path.slice(parent.path === '/' ? 1 : parent.path.length + 1);
return {
path,
name,
type: 'directory',
parent,
children: null,
state: 'collapsed',
isTruncated: false,
loadToken: 0,
element: null,
depth: parent.depth + 1,
unavailable: false,
};
}
/** Whether a node can be expanded (only directories, excludes unavailable). */
private isExpandable(node: TreeNode): boolean {
return node.type === 'directory' && !node.unavailable;
}
// ── 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');
if (node.unavailable) row.classList.add('is-unavailable');
row.style.setProperty('--file-tree-depth', String(node.depth));
row.dataset.path = node.path;
row.replaceChildren();
const expandable = this.isExpandable(node);
// Toggle / spinner / lock / 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 (node.unavailable) {
// Lock icon for nodes whose SFTP list failed (e.g. Permission denied
// on an ancestor). Not expandable; preserves full path in the tree.
const lock = document.createElement('span');
lock.className = 'file-tree-lock';
lock.setAttribute('aria-hidden', 'true');
lock.textContent = '\u{1F512}'; // 🔒
row.append(lock);
} 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: treat "loading" as expanded since the expand is in progress.
if (expandable) {
row.setAttribute('aria-expanded', String(node.state === 'expanded' || node.state === 'loading'));
} 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, silent = false): Promise<void> {
// Not expandable or already expanded → no-op.
if (!this.isExpandable(node) || node.state === 'expanded') return;
// Node is currently loading — return the in-flight promise so callers
// (e.g. a second expandToPath) can await the result rather than
// bailing out prematurely. This fixes the race condition triggered
// when FileManager double-emits setCwd (sftp_ready + sftp_list_result).
if (node.state === 'loading') {
const inflight = this.inflightExpands.get(node);
if (inflight) return inflight;
// Defensive fallback: shouldn't happen if inflightExpands is always
// set before transitioning to 'loading'.
return;
}
// Capture the tree generation this call belongs to.
const epoch = this.treeEpoch;
node.loadToken++;
const token = node.loadToken;
node.state = 'loading';
this.refreshRowContent(node);
// Create the in-flight promise and register it so concurrent callers
// can await it instead of creating a duplicate fetch.
let resolve: () => void = () => {};
const promise = new Promise<void>((res) => { resolve = res; });
this.inflightExpands.set(node, promise);
try {
const result = await this.fetchEntries(node.path);
// Tree-level guard: the tree was destroyed or rebuilt while awaiting,
// so `node` is orphaned. Return without rolling back node.state —
// the node died together with its tree and is unreachable.
if (epoch !== this.treeEpoch || !this.ready || !this.root) { resolve(); return; }
// Per-node race-condition guard: if loadToken changed, this was superseded.
if (token !== node.loadToken) { resolve(); return; }
node.children = result.entries.map((entry) => this.createTreeNode(entry, node));
node.isTruncated = result.isTruncated;
node.state = 'expanded';
this.refreshRowContent(node);
this.renderChildren(node);
resolve();
} catch (error) {
// Tree-level guard MUST run before onError: a fetch rejected by the
// teardown itself (rejectTreeLists on close/destroy) would otherwise
// surface a bogus error toast for a session the user already left.
if (epoch !== this.treeEpoch || !this.ready || !this.root) { resolve(); return; }
if (token !== node.loadToken) { resolve(); return; }
// ── Silent failure handling for setCwd auto-expansion ────────────
// When the tree is auto-walking to cwd and an ancestor fetch fails
// (e.g. restricted shell where / is unreadable), mark the node as
// unavailable instead of re-rooting. The walk will continue via a
// synthetic child in expandToPath, preserving the full path display.
// Manual expand (user click/keyboard) does NOT pass silent=true, so
// those errors still surface via the error path below.
if (silent) {
const cwd = this.selectedPath;
if (cwd && isDescendantPath(node.path, cwd)) {
node.unavailable = true;
node.state = 'collapsed';
node.children = null;
this.removeDescendantRows(node);
this.refreshRowContent(node);
resolve();
return;
}
}
// ── End silent handling ──────────────────────────────────────────
node.state = 'error';
node.children = null;
this.removeDescendantRows(node);
this.refreshRowContent(node);
const message = error instanceof Error ? error.message : String(error);
this.onError?.(message);
resolve();
} finally {
// Clean up the inflight map entry, but only if our promise is still
// the one registered (prevents clobbering a new expand on the same
// node that started after this one completed/cleaned up).
if (this.inflightExpands.get(node) === promise) {
this.inflightExpands.delete(node);
}
}
}
/** 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;
const segments = splitPathSegments(cwd);
let current = this.root;
for (const segment of segments) {
if (token !== this.setCwdToken) return;
if (segment === current.path) {
// Even when the segment matches the current node (e.g. root "/" on
// initial load or setCwd("/")), we must still expand it so its
// children are rendered in the tree. Without this, clicking the root
// loads the right panel but the left tree stays collapsed.
if (current.state !== 'expanded') {
await this.expandNode(current, true);
if (token !== this.setCwdToken) return;
}
continue;
}
// If current is unavailable, we can't see its real children.
// Create a synthetic child for the next segment so the walk can
// continue. The synthetic child starts as "potentially available"
// and will be tried on the next iteration.
if (current.unavailable) {
// Check if a synthetic child for this segment already exists
let synth = current.children?.find((c) => c.path === segment) ?? null;
if (!synth) {
synth = this.createUnavailableChild(segment, current);
if (!current.children) current.children = [];
current.children.push(synth);
this.renderChildren(current);
}
current = synth;
continue;
}
// Try to expand current (silent, no error UI on failure).
if (current.state !== 'expanded') {
await this.expandNode(current, true);
if (token !== this.setCwdToken) return;
// Expansion failed and marked current as unavailable: create
// a synthetic child for the next segment.
if (current.unavailable) {
let synth = current.children?.find((c) => c.path === segment) ?? null;
if (!synth) {
synth = this.createUnavailableChild(segment, current);
if (!current.children) current.children = [];
current.children.push(synth);
this.renderChildren(current);
}
current = synth;
continue;
}
}
// Find the real child matching the next segment.
const child = current.children?.find((c) => c.path === segment);
if (!child) break; // path segment not found in directory
current = child;
}
if (token !== this.setCwdToken) return;
// If the final node is a synthetic child of an unavailable parent,
// try expanding it (the loop's post-expansion check above may have
// created it and skipped to the next iteration, which then
// short-circuited because segment === current.path).
if (current.state === 'collapsed' && !current.unavailable && current.parent?.unavailable) {
await this.expandNode(current, true);
if (token !== this.setCwdToken) return;
}
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' && !node.unavailable) {
this.onNavigate(node.path);
// Expand the node in the tree immediately so aria-expanded flips to
// "true" (or shows a loading spinner) without waiting for the async
// setCwd → expandToPath round-trip that follows onNavigate.
void this.expandNode(node);
}
// Files/symlinks: select only, no right-side navigation
// Unavailable directories: highlight but don't navigate (SFTP would
// refuse and surface an error to the user).
}
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');
}
}