6 files changed
+373
-133
No files matched your search
@@ -102,6 +102,11 @@ const DISCONNECTED_CHANNEL: LocalizedText = {
|
||||
en: 'File management connection lost',
|
||||
};
|
||||
|
||||
// Auto-reconnect backoff for the file manager after an unexpected drop.
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 15_000;
|
||||
const RECONNECT_MAX_ATTEMPTS = 10;
|
||||
|
||||
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}`);
|
||||
@@ -212,6 +217,14 @@ export class FileManager {
|
||||
private uploadConfirmationPending = false;
|
||||
private messageQueue: Promise<void> = Promise.resolve();
|
||||
private statusCopy: LocalizedText = DISCONNECTED;
|
||||
/** Auto-reconnect state: true while the SSH session is alive and we want a file channel. */
|
||||
private wantConnection = false;
|
||||
/** Last attach URL, saved for reconnection. */
|
||||
private url: string | null = null;
|
||||
/** Pending reconnect timer id, null when idle. */
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Current reconnect attempt count (0 = no attempt). */
|
||||
private reconnectAttempts = 0;
|
||||
/** 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;
|
||||
@@ -233,7 +246,10 @@ export class FileManager {
|
||||
}
|
||||
|
||||
attach(url: string): void {
|
||||
this.clearReconnectTimer();
|
||||
this.reset();
|
||||
this.wantConnection = true;
|
||||
this.url = url;
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(url, window.location.href);
|
||||
@@ -257,6 +273,7 @@ export class FileManager {
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
if (!this.isCurrent(socket, generation)) return;
|
||||
this.reconnectAttempts = 0;
|
||||
this.init();
|
||||
});
|
||||
socket.addEventListener('message', (event: MessageEvent<SocketData>) => {
|
||||
@@ -277,7 +294,16 @@ export class FileManager {
|
||||
this.pending.clear();
|
||||
this.activeListRequest = null;
|
||||
this.rejectTreeLists(new Error('SFTP connection closed'));
|
||||
this.renderDisconnected(true, this.describeClose(event));
|
||||
if (event.code !== 1000 && event.code !== 1005) {
|
||||
// Unexpected drop. Auto-reconnect unless the user intentionally tore the session down.
|
||||
if (this.wantConnection) {
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this.renderDisconnected(true, this.describeClose(event));
|
||||
}
|
||||
} else {
|
||||
this.renderDisconnected(true, this.describeClose(event));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,6 +314,9 @@ export class FileManager {
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.wantConnection = false;
|
||||
this.clearReconnectTimer();
|
||||
this.reconnectAttempts = 0;
|
||||
this.generation++;
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
@@ -1186,6 +1215,50 @@ export class FileManager {
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (!this.wantConnection || !this.url || this.reconnectTimer !== null) return;
|
||||
this.reconnectAttempts++;
|
||||
if (this.reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
|
||||
this.reconnectAttempts = 0;
|
||||
this.renderDisconnected(true, { zh: '文件管理重连失败。', en: 'File manager reconnect failed.' });
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * (2 ** (this.reconnectAttempts - 1)));
|
||||
this.setStatus({
|
||||
zh: '文件管理连接已断开,正在重连…',
|
||||
en: 'File manager disconnected; reconnecting…',
|
||||
});
|
||||
const attempts = this.reconnectAttempts;
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (!this.wantConnection || !this.url) return;
|
||||
try {
|
||||
this.attach(this.url);
|
||||
} catch {
|
||||
// attach() → reset() zeroed the counter; restore so the next
|
||||
// close handler's scheduleReconnect() continues accumulating.
|
||||
if (this.wantConnection) {
|
||||
this.reconnectAttempts = attempts;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
// Successful attach(); reset() already zeroed the counter.
|
||||
// Restore so that if a close fires before the 'open' handler
|
||||
// can confirm the connection, the back-off chain continues.
|
||||
if (this.wantConnection) {
|
||||
this.reconnectAttempts = attempts;
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(copy: LocalizedText): void {
|
||||
this.updateStatusText(copy);
|
||||
this.onStatus?.(this.localize(copy));
|
||||
|
||||
+203
-51
@@ -50,6 +50,12 @@ interface TreeNode {
|
||||
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 {
|
||||
@@ -122,11 +128,22 @@ export class FileTree {
|
||||
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())
|
||||
* entire tree was torn down (setReady(false) / destroy())
|
||||
* 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`.
|
||||
@@ -187,18 +204,6 @@ export class FileTree {
|
||||
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).
|
||||
@@ -206,6 +211,13 @@ export class FileTree {
|
||||
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);
|
||||
}
|
||||
@@ -242,6 +254,7 @@ export class FileTree {
|
||||
loadToken: 0,
|
||||
element: null,
|
||||
depth: 0,
|
||||
unavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,12 +273,36 @@ export class FileTree {
|
||||
loadToken: 0,
|
||||
element: null,
|
||||
depth: parent.depth + 1,
|
||||
unavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a node can be expanded (only directories). */
|
||||
/**
|
||||
* 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';
|
||||
return node.type === 'directory' && !node.unavailable;
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
@@ -310,6 +347,7 @@ export class FileTree {
|
||||
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;
|
||||
@@ -317,12 +355,20 @@ export class FileTree {
|
||||
|
||||
const expandable = this.isExpandable(node);
|
||||
|
||||
// Toggle / spinner / spacer
|
||||
// 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';
|
||||
@@ -367,9 +413,9 @@ export class FileTree {
|
||||
row.append(retry);
|
||||
}
|
||||
|
||||
// ARIA expanded
|
||||
// ARIA expanded: treat "loading" as expanded since the expand is in progress.
|
||||
if (expandable) {
|
||||
row.setAttribute('aria-expanded', String(node.state === 'expanded'));
|
||||
row.setAttribute('aria-expanded', String(node.state === 'expanded' || node.state === 'loading'));
|
||||
} else {
|
||||
row.removeAttribute('aria-expanded');
|
||||
}
|
||||
@@ -437,8 +483,21 @@ export class FileTree {
|
||||
* - `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;
|
||||
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;
|
||||
@@ -448,26 +507,54 @@ export class FileTree {
|
||||
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) return;
|
||||
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) return;
|
||||
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) return;
|
||||
if (token !== node.loadToken) return;
|
||||
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;
|
||||
@@ -475,6 +562,14 @@ export class FileTree {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,41 +594,87 @@ export class FileTree {
|
||||
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);
|
||||
let current = this.root;
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
// Find the child matching this path segment
|
||||
// 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; highlight deepest ancestor
|
||||
if (!child) break; // path segment not found in directory
|
||||
current = child;
|
||||
}
|
||||
|
||||
if (token !== this.setCwdToken) return; // cancelled
|
||||
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;
|
||||
}
|
||||
|
||||
// Expand the target directory itself (not just its ancestors) so its
|
||||
// children are immediately rendered and aria-expanded is "true".
|
||||
// This covers initial-connection setCwd where activateNode is never
|
||||
// called — ancestors were expanded during the loop above; the target
|
||||
// needs this extra step.
|
||||
if (current.state === 'collapsed' && current.type === 'directory' && !current.unavailable) {
|
||||
await this.expandNode(current, true);
|
||||
if (token !== this.setCwdToken) return;
|
||||
}
|
||||
|
||||
// Highlight and scroll to the target node
|
||||
this.highlightNode(current);
|
||||
this.focusedNode = current;
|
||||
this.updateTabindex();
|
||||
@@ -605,10 +746,21 @@ export class FileTree {
|
||||
this.highlightNode(node);
|
||||
this.focusedNode = node;
|
||||
this.updateTabindex();
|
||||
if (node.type === 'directory') {
|
||||
if ((node.type === 'directory' || node.type === 'symlink') && !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.
|
||||
// Only real directories are expandable; symlinks navigate the right
|
||||
// panel but are not expanded in the tree (isExpandable returns false).
|
||||
if (node.type === 'directory') {
|
||||
void this.expandNode(node);
|
||||
}
|
||||
}
|
||||
// Files/symlinks: select only, no right-side navigation
|
||||
// Symlinks: navigate the right panel but are not expandable in the
|
||||
// tree (isExpandable returns false). Files: select only, no navigation.
|
||||
// Unavailable directories: highlight but don't navigate (SFTP would
|
||||
// refuse and surface an error to the user).
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent): void {
|
||||
|
||||
+28
-21
@@ -393,6 +393,7 @@ let authorizationAbort: AbortController | null = null;
|
||||
let currentExpectedFingerprint = '';
|
||||
let currentRememberedFingerprint = '';
|
||||
let currentSessionSubtitle: LocalizedMessage = { zh: '选择目标并连接', en: 'Choose a target and connect' };
|
||||
let currentSessionId = '';
|
||||
let currentEventMessage: LocalizedMessage = { zh: 'Worker 运行时待命', en: 'Worker runtime standing by' };
|
||||
let currentFormError: LocalizedMessage | null = null;
|
||||
let passwordDirty = false;
|
||||
@@ -402,8 +403,7 @@ let historyPasswordLoadGeneration = 0;
|
||||
let historyMutationSequence = 0;
|
||||
let keyFileReadGeneration = 0;
|
||||
const latestHistoryMutation = new Map<string, number>();
|
||||
const panelMedia = window.matchMedia('(max-width: 760px)');
|
||||
let panelOpen = !panelMedia.matches;
|
||||
let panelOpen = false;
|
||||
let fileManager: FileManager;
|
||||
let fileTree: FileTree;
|
||||
let processManager: ProcessManager;
|
||||
@@ -1072,6 +1072,16 @@ function toast(message: string, kind: 'info' | 'error' = 'info'): void {
|
||||
window.setTimeout(() => item.remove(), 4_500);
|
||||
}
|
||||
|
||||
function updateConnectionStatus(message: LocalizedMessage): void {
|
||||
currentSessionSubtitle = message;
|
||||
const text = localize(message);
|
||||
ui.sessionSubtitle.textContent = text;
|
||||
if (connectionState === 'connecting') {
|
||||
const btnSpan = ui.connect.querySelector<HTMLElement>('span:last-child');
|
||||
if (btnSpan) btnSpan.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function setState(state: ConnectionState, label?: string): void {
|
||||
connectionState = state;
|
||||
const stateLabel = label ?? ({
|
||||
@@ -1098,6 +1108,7 @@ function setState(state: ConnectionState, label?: string): void {
|
||||
ui.connect.querySelector<HTMLElement>('.button-icon')!.textContent = control.danger ? 'x' : '>_';
|
||||
ui.connect.querySelector<HTMLElement>('span:last-child')!.textContent = controlLabel;
|
||||
ui.connect.setAttribute('aria-label', controlLabel);
|
||||
ui.connect.title = currentSessionId || controlLabel;
|
||||
}
|
||||
|
||||
function event(message: string, category = 'session', error = false, alternate?: string): void {
|
||||
@@ -1130,13 +1141,13 @@ function fitTerminal(send = true): void {
|
||||
socket.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows }));
|
||||
}
|
||||
} catch {
|
||||
// The terminal can be temporarily dimensionless during a mobile drawer transition.
|
||||
// The terminal can be temporarily dimensionless during a panel drawer transition.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setPanelOpen(open: boolean): void {
|
||||
const view = resolveConnectionPanel(open, panelMedia.matches);
|
||||
const view = resolveConnectionPanel(open);
|
||||
panelOpen = view.expanded;
|
||||
if (!view.expanded && (connectionState === 'connecting' || connectionState === 'connected' || connectionState === 'disconnecting')) {
|
||||
invalidateHistoryPasswordLoad();
|
||||
@@ -1193,13 +1204,12 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive
|
||||
if (connectionState === 'connected') return;
|
||||
invalidateHistoryPasswordLoad();
|
||||
setState('connected');
|
||||
if (panelMedia.matches) setPanelOpen(false);
|
||||
setPanelOpen(false);
|
||||
void saveConnectedProfile().catch(() => {
|
||||
toast(bilingual('连接成功,但无法更新历史记录。', 'Connected, but the history could not be updated.'), 'error');
|
||||
});
|
||||
startTimers();
|
||||
currentSessionSubtitle = messageTranslation(message);
|
||||
ui.sessionSubtitle.textContent = message;
|
||||
updateConnectionStatus(messageTranslation(message));
|
||||
event(message, 'ready');
|
||||
if (currentInitialCommand && !initialCommandSent) {
|
||||
initialCommandSent = true;
|
||||
@@ -1413,8 +1423,7 @@ function handleServerMessage(message: ServerMessage): void {
|
||||
if (type === 'status') {
|
||||
const text = bilingualServerMessage(message.message, message.event, 'SSH handshake in progress');
|
||||
event(text, message.event ?? 'status');
|
||||
currentSessionSubtitle = messageTranslation(text);
|
||||
ui.sessionSubtitle.textContent = text;
|
||||
updateConnectionStatus(messageTranslation(text));
|
||||
if (message.event === 'shell_ready' || message.event === 'ready') markReady(text);
|
||||
}
|
||||
}
|
||||
@@ -1457,6 +1466,7 @@ function sendTerminalData(data: string): void {
|
||||
function failActiveConnection(activeSocket: WebSocket | null, closeReason: string, displayReason: LocalizedMessage): void {
|
||||
if (activeSocket && socket === activeSocket) socket = null;
|
||||
connectGeneration++;
|
||||
currentSessionId = '';
|
||||
pendingHistory = null;
|
||||
authorizationAbort?.abort();
|
||||
authorizationAbort = null;
|
||||
@@ -1468,8 +1478,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin
|
||||
processManager.reset();
|
||||
clearHostKeyPrompt();
|
||||
invalidateHistoryPasswordLoad();
|
||||
currentSessionSubtitle = displayReason;
|
||||
ui.sessionSubtitle.textContent = localize(displayReason);
|
||||
updateConnectionStatus(displayReason);
|
||||
setState('error');
|
||||
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(CLIENT_CLOSE_SESSION_ERROR, closeReason);
|
||||
}
|
||||
@@ -1537,8 +1546,7 @@ async function connect(): Promise<void> {
|
||||
awaitingHostKeyDecision = false;
|
||||
decoder = createDecoder(ui.encoding.value);
|
||||
ui.sessionTitle.textContent = currentTargetLabel;
|
||||
currentSessionSubtitle = localized('正在授权 Worker 会话...', 'Authorizing Worker session...');
|
||||
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
|
||||
updateConnectionStatus(localized('正在授权 Worker 会话...', 'Authorizing Worker session...'));
|
||||
ui.metricRtt.textContent = '--';
|
||||
ui.metricHostKey.textContent = '--';
|
||||
event(bilingual(`正在连接 ${currentTargetLabel}`, `Starting ${currentTargetLabel}`), 'connect');
|
||||
@@ -1554,6 +1562,7 @@ async function connect(): Promise<void> {
|
||||
pendingHistory = { generation, target: currentTargetKey, profile: historyProfile };
|
||||
const ticketRequest = issueTicket(abortController.signal);
|
||||
const { ticket, sessionId } = await ticketRequest;
|
||||
currentSessionId = sessionId;
|
||||
if (authorizationAbort === abortController) authorizationAbort = null;
|
||||
if (generation !== connectGeneration) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -1585,8 +1594,7 @@ async function connect(): Promise<void> {
|
||||
else config.privateKey = privateKey;
|
||||
if (pinnedKey) config.expectedFingerprint = pinnedKey;
|
||||
activeSocket.send(JSON.stringify(config));
|
||||
currentSessionSubtitle = localized('正在打开 TCP 连接...', 'Opening TCP connection...');
|
||||
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
|
||||
updateConnectionStatus(localized('正在打开 TCP 连接...', 'Opening TCP connection...'));
|
||||
event(bilingual('WebSocket 已建立,正在打开 SSH 传输。', 'WebSocket established; opening SSH transport.'), 'transport');
|
||||
}, { once: true });
|
||||
activeSocket.addEventListener('message', (socketEvent) => {
|
||||
@@ -1603,6 +1611,7 @@ async function connect(): Promise<void> {
|
||||
activeSocket.addEventListener('close', (closeEvent) => {
|
||||
if (socket !== activeSocket) return;
|
||||
socket = null;
|
||||
currentSessionId = '';
|
||||
pendingHistory = null;
|
||||
currentExpectedFingerprint = '';
|
||||
currentRememberedFingerprint = '';
|
||||
@@ -1619,8 +1628,7 @@ async function connect(): Promise<void> {
|
||||
? bilingual('会话已关闭。', 'Session closed.')
|
||||
: bilingual(`会话已关闭(${closeEvent.code})。`, `Session closed (${closeEvent.code}).`);
|
||||
event(reason, 'disconnect', closeEvent.code !== 1000 && closeEvent.code !== 1005);
|
||||
currentSessionSubtitle = messageTranslation(reason);
|
||||
ui.sessionSubtitle.textContent = reason;
|
||||
updateConnectionStatus(messageTranslation(reason));
|
||||
setState(closeEvent.code === 1000 || closeEvent.code === 1005 ? 'idle' : 'error');
|
||||
if (wasActive) toast(reason, closeEvent.code === 1000 || closeEvent.code === 1005 ? 'info' : 'error');
|
||||
});
|
||||
@@ -1646,6 +1654,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
|
||||
if (connectionState === 'disconnecting') return;
|
||||
const generation = ++connectGeneration;
|
||||
setState('disconnecting');
|
||||
currentSessionId = '';
|
||||
pendingHistory = null;
|
||||
authorizationAbort?.abort();
|
||||
authorizationAbort = null;
|
||||
@@ -1659,8 +1668,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
|
||||
invalidateHistoryPasswordLoad();
|
||||
currentExpectedFingerprint = '';
|
||||
currentRememberedFingerprint = '';
|
||||
currentSessionSubtitle = messageTranslation(reason);
|
||||
ui.sessionSubtitle.textContent = reason;
|
||||
updateConnectionStatus(messageTranslation(reason));
|
||||
event(reason, 'disconnect');
|
||||
let settled = false;
|
||||
const finish = (): void => {
|
||||
@@ -1947,12 +1955,11 @@ window.addEventListener('beforeunload', () => {
|
||||
socket?.close(1000, 'Page closed');
|
||||
});
|
||||
document.addEventListener('keydown', (keyEvent) => {
|
||||
if (keyEvent.key === 'Escape' && panelMedia.matches && panelOpen && !ui.hostKeyDialog.open) {
|
||||
if (keyEvent.key === 'Escape' && panelOpen && !ui.hostKeyDialog.open) {
|
||||
setPanelOpen(false);
|
||||
ui.panelToggle.focus();
|
||||
}
|
||||
});
|
||||
panelMedia.addEventListener('change', (mediaEvent) => setPanelOpen(!mediaEvent.matches));
|
||||
|
||||
let storedTheme: string | null = null;
|
||||
try { storedTheme = localStorage.getItem(THEME_STORAGE_KEY); } catch { /* Storage can be disabled. */ }
|
||||
|
||||
+49
-46
@@ -204,7 +204,7 @@ button:disabled {
|
||||
}
|
||||
|
||||
#panel-toggle {
|
||||
display: none;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
@@ -359,22 +359,31 @@ button:disabled {
|
||||
.app-shell {
|
||||
display: grid;
|
||||
height: calc(100% - var(--topbar-height));
|
||||
grid-template-columns: minmax(300px, 340px) minmax(0, 1fr);
|
||||
transition: grid-template-columns 180ms ease;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
position: fixed;
|
||||
top: var(--topbar-height);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
width: min(90vw, 350px);
|
||||
padding: 24px 20px max(24px, env(safe-area-inset-bottom));
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
scrollbar-color: var(--surface-3) transparent;
|
||||
scrollbar-width: thin;
|
||||
transition: opacity 140ms ease;
|
||||
transform: translateX(-102%);
|
||||
transition: transform 220ms cubic-bezier(0.2, 0.75, 0.2, 1);
|
||||
}
|
||||
|
||||
.connection-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.panel-heading,
|
||||
@@ -855,10 +864,18 @@ button:disabled {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
background: var(--accent);
|
||||
color: #06130e;
|
||||
}
|
||||
|
||||
.primary-button > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme='light'] .primary-button {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -1082,7 +1099,7 @@ button:disabled {
|
||||
}
|
||||
|
||||
.terminal-card.workspace-panel-open {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(34px, 1fr);
|
||||
grid-template-rows: minmax(0, 1.618fr) minmax(34px, 1fr);
|
||||
}
|
||||
|
||||
.terminal-pane {
|
||||
@@ -1519,6 +1536,21 @@ button:disabled {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.file-tree-row.is-unavailable {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.file-tree-row.is-unavailable .file-tree-name {
|
||||
color: var(--color-text-muted, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
.file-tree-lock {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.file-tree-retry {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 4px;
|
||||
@@ -2172,7 +2204,16 @@ button:disabled {
|
||||
}
|
||||
|
||||
.panel-scrim {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: var(--topbar-height) 0 0;
|
||||
z-index: 40;
|
||||
display: block;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: rgba(1, 5, 8, 0.68);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
@@ -2184,10 +2225,6 @@ button:disabled {
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-shell {
|
||||
grid-template-columns: minmax(280px, 310px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.session-metrics > div {
|
||||
min-width: 58px;
|
||||
padding: 0 5px;
|
||||
@@ -2241,40 +2278,6 @@ button:disabled {
|
||||
height: calc(100% - var(--topbar-height));
|
||||
}
|
||||
|
||||
#panel-toggle {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
position: fixed;
|
||||
top: var(--topbar-height);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
width: min(90vw, 350px);
|
||||
border-right: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateX(-102%);
|
||||
transition: transform 220ms cubic-bezier(0.2, 0.75, 0.2, 1);
|
||||
}
|
||||
|
||||
.connection-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.panel-scrim {
|
||||
position: fixed;
|
||||
inset: var(--topbar-height) 0 0;
|
||||
z-index: 40;
|
||||
display: block;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: rgba(1, 5, 8, 0.68);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
|
||||
@@ -20,10 +20,10 @@ export interface ConnectionPanelView {
|
||||
scrimVisible: boolean;
|
||||
}
|
||||
|
||||
export function resolveConnectionPanel(open: boolean, mobile: boolean): ConnectionPanelView {
|
||||
export function resolveConnectionPanel(open: boolean): ConnectionPanelView {
|
||||
return {
|
||||
expanded: !mobile || open,
|
||||
drawerOpen: mobile && open,
|
||||
scrimVisible: mobile && open,
|
||||
expanded: open,
|
||||
drawerOpen: open,
|
||||
scrimVisible: open,
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ interface SFTPAttachToken {
|
||||
mainWebSocket: WebSocket;
|
||||
attachUrl: string;
|
||||
expiresAt: number;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
session?: SSHSession;
|
||||
}
|
||||
type AuxiliaryAttachToken = SFTPAttachToken;
|
||||
@@ -249,9 +249,9 @@ export class SSHSessionDO implements DurableObject {
|
||||
}
|
||||
if (!attach.session) return Response.json({ error: 'SSH session is still initializing' }, { status: 409 });
|
||||
|
||||
// Consume before exposing the client endpoint so concurrent replays cannot
|
||||
// attach a second file-management channel to the same SSH session.
|
||||
this.deleteSFTPAttachToken(token, attach);
|
||||
// Keep the token valid after attach so the client can auto-reconnect
|
||||
// the file manager after a transient connection drop (e.g. code 1006).
|
||||
// It is deleted in cleanup() when the main SSH session closes.
|
||||
const pair = new WebSocketPair();
|
||||
const client = pair[0];
|
||||
const server = pair[1];
|
||||
@@ -305,12 +305,17 @@ export class SSHSessionDO implements DurableObject {
|
||||
}
|
||||
|
||||
private registerSFTPAttachToken(token: string, attachUrl: string, mainWebSocket: WebSocket): void {
|
||||
const expiresAt = Date.now() + SFTP_ATTACH_TOKEN_TTL_MS;
|
||||
// The SFTP token intentionally lives for the lifetime of the main SSH
|
||||
// session (it is removed in cleanup() when that session closes). This
|
||||
// lets the client re-attach the file manager after a transient drop
|
||||
// (e.g. WebSocket code 1006) instead of being locked out by a one-time
|
||||
// token. The SSH session setup still gates attach on attach.session
|
||||
// being ready.
|
||||
const attach: SFTPAttachToken = {
|
||||
mainWebSocket,
|
||||
attachUrl,
|
||||
expiresAt,
|
||||
timeout: setTimeout(() => this.deleteSFTPAttachToken(token, attach), SFTP_ATTACH_TOKEN_TTL_MS),
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
timeout: null,
|
||||
};
|
||||
this.sftpAttachTokens.set(token, attach);
|
||||
this.sftpTokenByMainWebSocket.set(mainWebSocket, token);
|
||||
@@ -325,7 +330,7 @@ export class SSHSessionDO implements DurableObject {
|
||||
mainWebSocket,
|
||||
attachUrl,
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
timeout: 0 as unknown as ReturnType<typeof setTimeout>,
|
||||
timeout: null,
|
||||
};
|
||||
this.processAttachTokens.set(token, attach);
|
||||
this.processTokenByMainWebSocket.set(mainWebSocket, token);
|
||||
@@ -334,7 +339,7 @@ export class SSHSessionDO implements DurableObject {
|
||||
private deleteProcessAttachToken(token: string, expected?: AuxiliaryAttachToken): void {
|
||||
const attach = this.processAttachTokens.get(token);
|
||||
if (!attach || (expected && attach !== expected)) return;
|
||||
clearTimeout(attach.timeout);
|
||||
if (attach.timeout !== null) clearTimeout(attach.timeout);
|
||||
this.processAttachTokens.delete(token);
|
||||
if (this.processTokenByMainWebSocket.get(attach.mainWebSocket) === token) {
|
||||
this.processTokenByMainWebSocket.delete(attach.mainWebSocket);
|
||||
@@ -344,7 +349,7 @@ export class SSHSessionDO implements DurableObject {
|
||||
private deleteSFTPAttachToken(token: string, expected?: SFTPAttachToken): void {
|
||||
const attach = this.sftpAttachTokens.get(token);
|
||||
if (!attach || (expected && attach !== expected)) return;
|
||||
clearTimeout(attach.timeout);
|
||||
if (attach.timeout !== null) clearTimeout(attach.timeout);
|
||||
this.sftpAttachTokens.delete(token);
|
||||
if (this.sftpTokenByMainWebSocket.get(attach.mainWebSocket) === token) {
|
||||
this.sftpTokenByMainWebSocket.delete(attach.mainWebSocket);
|
||||
|
||||
Reference in new issue
Block a user