diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f7607d5..79ef2af 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -59,6 +59,12 @@ jobs:
- name: Check project
run: npm run check
+ # 在模拟协议上回归共享文件组件,防止页面改动破坏终端侧栏或误操作真实主机。
+ - name: Check file manager in desktop and mobile browsers
+ run: |
+ npx playwright install --with-deps chromium
+ npm run test:browser -- --workers=2
+
- name: Configure selected login, provision D1, migrate and deploy Worker
run: npm run deploy
env:
diff --git a/.gitignore b/.gitignore
index 1e53566..e4e2dbd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@ deliverables/
frontend/dist-*
scripts-tmp/
.wrangler.generated.toml
+test-results/
+playwright-report/
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 57563b2..c9bd9b7 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -134,3 +134,5 @@ Cloudflare 模式仍可用 `ACCESS_IDP_IDS` 为新 Access 应用选择现成 IdP
- 登录后 `/api/auth/me` 与主机列表可用;无 JWT、伪造/过期 JWT 不可访问主机、票据或 WebSocket。
- 加密资料跨部署保持可解密;测试不得清空生产 D1。
- 真实 SSH、SFTP 与进程面板必须使用已获授权目标;未提供目标和登录会话时不声称验收完成。
+- 文件管理浏览器回归:`npx playwright install chromium` 后运行 `npm run test:browser`,覆盖桌面/375px 手机的入口、目录导航、上传/下载字节、新建/重命名/删除、主机切换、指纹确认与传输期间导航。测试只模拟 API 和 WebSocket,不替代真实服务器验收。
+- 线上登录后,从左侧「文件管理」选择授权主机,在专用临时目录验证上传、下载、新建、重命名与删除空目录,再切换到终端确认同一会话仍可用。不得用既有生产文件验证删除或覆盖。
diff --git a/README.md b/README.md
index dbcdf52..0cd2824 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@
📁 SFTP 文件管理
- 在终端旁浏览远程目录,上传与下载文件、新建目录、重命名,以及删除文件或空目录。
+ 从左侧「文件管理」进入专用页面,选择已保存主机,浏览目录、上传与下载、新建文件夹、重命名、删除文件或空目录;与终端侧栏共用同一文件组件与 SSH 会话。
|
@@ -186,6 +186,7 @@ npm run dev
| `npm run build:web` | 构建前端到 `dist/` |
| `npm run typecheck` | 检查 Worker 与前端类型 |
| `npm test` | 运行测试 |
+| `npm run test:browser` | 文件管理浏览器回归(首次运行前执行 `npx playwright install chromium`) |
| `npm run check` | 类型检查、测试与部署 dry-run(包含前端构建) |
| `npm run deploy` | 构建前端并部署 Worker |
@@ -228,6 +229,12 @@ EdgeSSH/
暂不支持加密私钥、PEM/PKCS#8 私钥、SSH Agent、多因素键盘交互认证、SCP、端口转发、ProxyJump、SSH 压缩与会话内 rekey;不支持出站 TCP 25 端口。
+### 独立文件管理
+
+左侧点击 **文件管理**,选择云端保存的主机并连接。首次连接或指纹变化时仍须确认主机身份。双击目录进入,或在路径栏输入绝对路径后按 Enter;选中文件后可下载、重命名、删除,也可上传文件和新建文件夹。
+
+文件页继续使用原有完整 SSH 会话,不要求服务器提供独立的 SFTP-only 入口;不会执行主机配置中的初始命令。点击 **打开 SSH 终端** 可继续使用同一会话,文件传输不会因视图切换而中断。切换主机前先断开连接;离开到总览会关闭会话,正在传输时会要求确认。
+
## 参与贡献
欢迎通过 Issue 反馈问题或通过 Pull Request 提交改进。提交前请运行 `npm run check`;涉及连接、文件传输或进程面板的改动,请使用真实且已获授权的 SSH 目标验证。
diff --git a/frontend/index.html b/frontend/index.html
index c51b424..0b843c6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -480,7 +480,7 @@
-
+
diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts
index bd19b92..0bf4ab3 100644
--- a/frontend/src/dashboard.ts
+++ b/frontend/src/dashboard.ts
@@ -1,10 +1,12 @@
import { api, saveHost, removeHost, refreshHostLocation, type CloudHost, type HostInput } from './cloud-api';
import type { HostGlobe } from './globe';
+import type { FilePage } from './file-page';
import { countryFlag } from './flags';
import { systemIcon } from './os-icons';
import './dashboard.css';
interface DashboardActions {
+ files: FilePage;
refresh(): Promise;
connect(host: CloudHost): Promise;
quickConnect(): void;
@@ -15,6 +17,7 @@ const icons = {
home: '',
server: '',
terminal: '',
+ folder: '',
shield: '',
search: '',
refresh: '',
@@ -46,8 +49,9 @@ export class Dashboard {
@@ -100,6 +104,7 @@ export class Dashboard {
`;
document.body.prepend(this.root);
+ this.get('.home-layout').append(this.actions.files.root);
this.dialog = this.get
('.host-dialog');
this.form = this.get('#cloud-host-form');
this.get('#account-action').addEventListener('click', async (event) => {
@@ -117,9 +122,16 @@ export class Dashboard {
this.dialog.addEventListener('close', () => { this.form.reset(); this.editing = undefined; this.returnFocus?.focus(); });
this.get('#host-search').addEventListener('input', () => this.renderList());
this.get('#refresh-hosts').addEventListener('click', () => void this.refresh());
- this.get('#rail-hosts').addEventListener('click', () => this.get('#host-search').focus());
+ for (const id of ['#rail-overview', '#rail-hosts']) this.get(id).addEventListener('click', () => {
+ if (!this.actions.files.confirmLeave()) return;
+ if (!this.isHome) this.actions.leaveWorkspace();
+ this.show();
+ if (id === '#rail-hosts') this.get('#host-search').focus();
+ });
+ this.get('#rail-files').addEventListener('click', () => this.showFiles());
for (const id of ['#quick-connect', '#bottom-quick']) this.get(id).addEventListener('click', () => {
- if (this.busy) return;
+ if (this.busy || !this.actions.files.confirmLeave()) return;
+ this.actions.leaveWorkspace();
this.openWorkspace(); this.actions.quickConnect();
});
this.field('authMethod').addEventListener('change', () => this.updateCredentialFields());
@@ -137,8 +149,15 @@ export class Dashboard {
});
const back = document.createElement('button');
back.className = 'icon-button home-back'; back.textContent = '← 主机总览'; back.type = 'button';
- back.addEventListener('click', () => { this.actions.leaveWorkspace(); this.show(); void this.refresh(); });
+ back.addEventListener('click', () => {
+ if (!this.actions.files.confirmLeave()) return;
+ this.actions.leaveWorkspace(); this.show(); void this.refresh();
+ });
document.querySelector('#app .topbar-actions')!.prepend(back);
+ const files = document.createElement('button');
+ files.className = 'icon-button home-back'; files.textContent = '文件管理'; files.type = 'button';
+ files.addEventListener('click', () => this.showFiles());
+ document.querySelector('#app .topbar-actions')!.prepend(files);
this.show();
}
@@ -171,6 +190,7 @@ export class Dashboard {
setHosts(hosts: CloudHost[]): void {
this.hosts = hosts;
+ this.actions.files.setHosts(hosts);
if (this.group && !hosts.some((host) => host.group === this.group)) this.group = '';
this.renderFilters(); this.renderList();
this.globe?.setHosts(hosts);
@@ -187,13 +207,17 @@ export class Dashboard {
}
show(): void {
+ this.actions.files.hide();
this.isHome = true; this.root.hidden = false;
+ this.get('.home-content').hidden = false;
+ this.selectNavigation('rail-overview');
document.getElementById('app')!.hidden = true;
document.body.dataset.view = 'dashboard';
this.globe?.setActive(!this.paused);
}
openWorkspace(): void {
+ this.actions.files.hide();
this.isHome = false; this.root.hidden = true;
document.getElementById('app')!.hidden = false;
document.body.dataset.view = 'workspace';
@@ -201,11 +225,30 @@ export class Dashboard {
window.dispatchEvent(new Event('resize'));
}
+ showFiles(): void {
+ this.isHome = false; this.root.hidden = false;
+ this.get('.home-content').hidden = true;
+ document.getElementById('app')!.hidden = true;
+ document.body.dataset.view = 'files';
+ this.selectNavigation('rail-files');
+ this.globe?.setActive(false);
+ this.actions.files.show();
+ }
+
+ private selectNavigation(id: string): void {
+ this.root.querySelectorAll('.rail-item').forEach((button) => {
+ button.classList.toggle('selected', button.id === id);
+ if (button.id === id) button.setAttribute('aria-current', 'page');
+ else button.removeAttribute('aria-current');
+ });
+ }
+
private notice(message: string): void {
const notice = this.get('#home-notice'); notice.textContent = message; notice.hidden = false;
}
private signedOut(): void {
+ this.actions.leaveWorkspace();
this.authenticated = false;
this.get('#account-label').textContent = '未登录';
const action = this.get('#account-action');
diff --git a/frontend/src/file-manager.ts b/frontend/src/file-manager.ts
index d317e01..3d5aebb 100644
--- a/frontend/src/file-manager.ts
+++ b/frontend/src/file-manager.ts
@@ -1180,7 +1180,8 @@ export class FileManager {
this.elements.path.disabled = !this.ready;
this.elements.back.disabled = !this.ready || this.historyIndex <= 0;
this.elements.up.disabled = !this.ready || this.cwd === '/';
- this.elements.home.disabled = !this.ready || this.cwd === this.homePath;
+ // 列目录失败时 cwd 仍是上次成功路径,允许用主目录按钮恢复,而不把错误页锁在原处。
+ this.elements.home.disabled = !this.ready || (this.cwd === this.homePath && this.elements.error.hidden);
this.elements.refresh.disabled = !this.ready;
this.elements.upload.disabled = !idle;
this.elements.mkdir.disabled = !idle;
diff --git a/frontend/src/file-page.css b/frontend/src/file-page.css
new file mode 100644
index 0000000..f397144
--- /dev/null
+++ b/frontend/src/file-page.css
@@ -0,0 +1,115 @@
+/* 文件页沿用总览的白橙主题;局部映射共享面板令牌,不改变终端侧栏的紧凑布局。 */
+body[data-view='files'] { height: auto; min-height: 100%; overflow-y: auto; background: var(--home-bg); }
+.files-page {
+ --surface: var(--home-surface);
+ --surface-2: var(--home-bg);
+ --surface-3: var(--home-line);
+ --panel: var(--home-surface);
+ --border: var(--home-line);
+ --border-soft: var(--home-line);
+ --text: var(--home-ink);
+ --muted: var(--home-muted);
+ --muted-2: var(--home-muted);
+ --accent: var(--home-accent-hover);
+ --accent-dim: var(--home-accent-soft);
+ --danger: var(--home-danger);
+ --danger-dim: var(--home-accent-soft);
+ --warning: var(--home-accent-hover);
+ --sans: var(--home-font);
+ --mono: var(--home-mono);
+ min-width: 0; width: 100%; max-width: 1700px; margin-inline: auto;
+ padding: 32px; display: flex; flex-direction: column; gap: 20px;
+}
+.files-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
+.files-heading h1 { margin: 0; font-size: 29px; line-height: 1.3; letter-spacing: -.8px; }
+.files-heading p:last-child { margin: 8px 0 0; color: var(--home-muted); }
+.files-connection { display: flex; align-items: center; gap: 16px; padding: 18px 20px; border: 1px solid var(--home-line); border-radius: 10px; background: var(--home-surface); }
+.files-host-icon { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 9px; background: var(--home-accent-soft); color: var(--home-accent-hover); flex-shrink: 0; }
+.files-host-field { display: flex; flex-direction: column; gap: 6px; flex: 1; max-width: 510px; min-width: 0; color: var(--home-muted); font-size: 12px; }
+#dashboard .files-host-field select { width: 100%; min-width: 0; height: 42px; padding: 0 32px 0 12px; color: var(--home-ink); background: var(--home-bg); border: 1px solid var(--home-line); border-radius: 6px; font: 13px var(--home-mono); }
+#files-connect { margin-top: 24px; min-width: 104px; }
+.files-connection-meta { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; font-size: 12px; color: var(--home-muted); text-align: right; }
+.files-state { display: inline-flex; align-items: center; gap: 7px; color: var(--home-muted); font-weight: 600; }
+.files-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
+.files-state[data-state='connected'] { color: var(--home-success); }
+.files-state[data-state='error'] { color: var(--home-danger); }
+.files-state[data-state='connecting'] { color: var(--home-accent-hover); }
+.files-notice { margin: 0; padding: 10px 14px; border-left: 3px solid var(--home-accent); background: var(--home-accent-soft); color: var(--home-ink); font-size: 13px; overflow-wrap: anywhere; }
+.files-notice.error { color: var(--home-danger); border-color: var(--home-danger); }
+.files-browser { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--home-line); border-radius: 10px; overflow: hidden; background: var(--home-surface); }
+.files-browser-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--home-line); }
+.files-browser-heading strong { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; }
+.files-browser-heading strong svg { color: var(--home-accent-hover); }
+.files-browser-heading > span { color: var(--home-muted); font: 11px var(--home-mono); }
+.files-panel-slot { display: grid; min-width: 0; height: clamp(390px, calc(100dvh - 490px), 850px); }
+.files-page .file-manager { grid-template-rows: auto minmax(0, 1fr) auto; }
+.files-page .file-toolbar { flex-wrap: wrap; gap: 12px; padding: 14px 18px; }
+.files-page .file-path-field { flex-basis: 100%; }
+.files-page .file-path-field input { height: 40px; font-size: 13px; padding-inline: 12px; }
+.files-page .file-actions { flex: 1; flex-wrap: wrap; gap: 6px; overflow: visible; }
+#dashboard .files-page .file-action { min-width: 38px; height: 38px; border: 1px solid var(--home-line); background: var(--home-surface); font-size: 14px; }
+#dashboard .files-page .file-action-wide { padding-inline: 14px; font-size: 13px; font-weight: 500; }
+#dashboard .files-page #file-upload { background: var(--home-accent-hover); color: var(--home-surface); border-color: var(--home-accent-hover); }
+#dashboard .files-page #file-delete { color: var(--home-danger); margin-left: auto; }
+#dashboard .files-page button:disabled { cursor: not-allowed; }
+.files-page .file-tree { background: var(--home-bg); }
+.files-page .file-tree-header { height: 42px; padding-inline: 14px; background: var(--home-bg); font-size: 12px; text-transform: none; }
+#dashboard .files-page .file-tree-collapse { width: 30px; height: 30px; font-size: 14px; }
+.files-page .file-manager:has(> .file-tree.is-collapsed) { grid-template-columns: 40px minmax(0, 1fr); }
+.files-page .file-tree-scroller { padding-block: 8px; }
+.files-page .file-tree-row { min-height: 34px; gap: 8px; font-size: 12px; }
+.files-page .file-tree-icon { font-size: 14px; }
+.files-page .file-table { font-size: 12px; }
+.files-page .file-table th { height: 42px; padding-inline: 12px; font-size: 12px; font-weight: 500; border-right: 0; }
+.files-page .file-table td { height: 42px; padding-inline: 12px; }
+.files-page .file-table th:first-child { width: 32%; }
+.files-page .file-table th:nth-child(6), .files-page .file-table th:nth-child(7) { width: 8.5%; }
+.files-page .file-kind-icon { width: 20px; flex-basis: 20px; font-size: 16px; }
+.files-page .file-manager-state { font-size: 14px; line-height: 1.8; }
+.files-page .file-statusbar { min-height: 42px; padding: 8px 16px; font-size: 11px; flex-wrap: wrap; }
+.files-page .file-progress { max-width: 100%; }
+#dashboard .files-page .file-progress-cancel { height: 30px; color: var(--home-ink); background: var(--home-bg); }
+.files-footer { display: flex; justify-content: space-between; gap: 12px; color: var(--home-muted); font-size: 12px; }
+body[data-view='files'] #dashboard .home-search, body[data-view='files'] #dashboard .header-add { display: none; }
+body[data-view='files'] .home-account { margin-left: auto; }
+@media (max-width: 1100px) {
+ .files-page { padding: 24px; }
+ .files-connection { flex-wrap: wrap; }
+ .files-connection-meta { flex-basis: 100%; flex-direction: row; align-items: center; justify-content: space-between; }
+ .files-page .file-manager { grid-template-columns: 180px minmax(0, 1fr); }
+ .files-page .file-table :is(th, td):nth-child(n+5) { display: none; }
+}
+@media (max-width: 600px) {
+ .files-page { padding: 20px 12px; gap: 16px; }
+ .files-heading { align-items: flex-start; }
+ .files-heading h1 { font-size: 25px; }
+ .files-heading p:last-child { font-size: 12px; }
+ #dashboard #files-terminal { min-height: 44px; padding-inline: 10px; font-size: 12px; }
+ .files-connection { padding: 14px; gap: 12px; }
+ .files-host-icon { display: none; }
+ .files-host-field { flex-basis: calc(100% - 116px); }
+ #dashboard .files-host-field select { height: 44px; font-size: 16px; }
+ #files-connect { min-height: 44px; }
+ .files-connection-meta { align-items: flex-start; text-align: left; font-size: 11px; }
+ .files-connection-meta > span:last-child { max-width: 65%; }
+ .files-browser-heading { padding: 14px 12px; }
+ .files-browser-heading > span { font-size: 10px; }
+ .files-page .file-toolbar { padding: 12px; }
+ .files-page .file-path-field input { font-size: 16px; height: 44px; }
+ #dashboard .files-page .file-action { min-width: 44px; height: 44px; }
+ #dashboard .files-page .file-action-wide { padding-inline: 10px; }
+ .files-page .file-action-separator { display: none; }
+ .files-page .file-manager { grid-template-columns: minmax(0, 1fr); grid-template-areas: "toolbar" "table" "status"; }
+ .files-page .file-manager > .file-tree { display: none; }
+ .files-page .file-manager:has(> .file-tree.is-collapsed) { grid-template-columns: minmax(0, 1fr); }
+ .files-page .file-table th:first-child { width: 48%; }
+ .files-page .file-table th:nth-child(2) { width: 20%; }
+ .files-page .file-table th:nth-child(4) { width: 32%; }
+ .files-page .file-table :is(th, td):nth-child(3) { display: none; }
+ .files-page .file-table td { height: 48px; }
+ .files-panel-slot { height: 560px; }
+ .files-footer { flex-direction: column; gap: 6px; }
+ #dashboard .home-rail { gap: 4px; padding-inline: 8px; justify-content: space-between; }
+ #dashboard .rail-item { padding-inline: 8px; font-size: 11px; }
+ #dashboard .rail-item svg { width: 18px; height: 18px; }
+}
diff --git a/frontend/src/file-page.ts b/frontend/src/file-page.ts
new file mode 100644
index 0000000..35953b0
--- /dev/null
+++ b/frontend/src/file-page.ts
@@ -0,0 +1,166 @@
+import type { CloudHost } from './cloud-api';
+import './file-page.css';
+
+export type FileConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnecting' | 'error';
+
+interface FilePageActions {
+ connect(host: CloudHost): Promise;
+ disconnect(): void;
+ openTerminal(): void;
+}
+
+const folderIcon = '';
+
+/** 页面只负责主机选择和布局;移动同一个面板,避免为同一 SSH 会话重复附着 SFTP。 */
+export class FilePage {
+ readonly root = document.createElement('main');
+ private readonly dockMarker = document.createComment('shared-file-manager');
+ private readonly select: HTMLSelectElement;
+ private readonly connectButton: HTMLButtonElement;
+ private hosts: CloudHost[] = [];
+ private state: FileConnectionState = 'idle';
+ private preparing = false;
+ private mounted = false;
+ private activeTarget: { id?: string; label: string } | null = null;
+
+ constructor(private readonly panel: HTMLElement, private readonly actions: FilePageActions) {
+ panel.before(this.dockMarker);
+ this.root.className = 'files-page';
+ this.root.hidden = true;
+ this.root.innerHTML = `
+
+ REMOTE FILES
文件管理
+
在服务器之间切换,让每一份文件井然有序。
+
+
+
+ ${folderIcon}
+
+
+ 未连接
+ 使用已保存的 SSH 凭据安全连接
+
+
+
+ ${folderIcon}远程目录SFTP · 加密传输
+
+
+ `;
+ this.select = this.get('#files-host');
+ this.connectButton = this.get('#files-connect');
+ this.select.addEventListener('change', () => this.renderConnection());
+ this.connectButton.addEventListener('click', () => void this.toggleConnection());
+ this.get('#files-terminal').addEventListener('click', () => this.actions.openTerminal());
+ }
+
+ private get(selector: string): T {
+ return this.root.querySelector(selector)!;
+ }
+
+ show(): void {
+ this.mounted = true;
+ this.root.hidden = false;
+ this.get('.files-panel-slot').append(this.panel);
+ this.panel.hidden = false;
+ this.panel.setAttribute('role', 'region');
+ this.panel.setAttribute('aria-labelledby', 'files-heading');
+ this.get('#files-heading').focus();
+ }
+
+ hide(): void {
+ if (!this.mounted) return;
+ this.mounted = false;
+ this.root.hidden = true;
+ this.dockMarker.after(this.panel);
+ this.panel.setAttribute('role', 'tabpanel');
+ this.panel.setAttribute('aria-labelledby', 'file-manager-tab');
+ this.panel.hidden = document.getElementById('file-manager-tab')!.getAttribute('aria-selected') !== 'true';
+ }
+
+ setHosts(hosts: CloudHost[]): void {
+ const selected = this.select.value;
+ this.hosts = hosts;
+ this.select.replaceChildren(new Option(hosts.length ? '请选择主机' : '暂无主机,请先在总览添加', ''));
+ for (const host of hosts) {
+ this.select.add(new Option(`${host.name} · ${host.username}@${host.host}:${host.port}`, host.id));
+ }
+ this.select.value = hosts.some((host) => host.id === selected) ? selected : '';
+ this.selectActiveTarget();
+ this.renderConnection();
+ }
+
+ setConnection(state: FileConnectionState, hostId?: string, target = ''): void {
+ this.state = state;
+ this.activeTarget = state === 'connecting' || state === 'connected' ? { id: hostId, label: target } : null;
+ this.selectActiveTarget();
+ this.renderConnection();
+ }
+
+ private selectActiveTarget(): void {
+ this.select.querySelector('[data-current-session]')?.remove();
+ if (!this.activeTarget) return;
+ if (this.hosts.some((host) => host.id === this.activeTarget!.id)) {
+ this.select.value = this.activeTarget.id!;
+ } else {
+ // 临时连接也能进入文件页,但不能把旧的下拉选项误标成当前服务器。
+ const option = new Option(`当前会话 · ${this.activeTarget.label}`, '');
+ option.dataset.currentSession = 'true';
+ this.select.add(option);
+ option.selected = true;
+ }
+ }
+
+ setMessage(message: string, error = false): void {
+ const notice = this.get('#files-notice');
+ notice.textContent = message;
+ notice.classList.toggle('error', error);
+ notice.hidden = !message;
+ }
+
+ private renderConnection(): void {
+ const active = this.state === 'connecting' || this.state === 'connected';
+ const busy = this.preparing || this.state === 'disconnecting';
+ this.select.disabled = active || busy || !this.hosts.length;
+ this.connectButton.disabled = busy || (!active && !this.select.value);
+ this.get('#files-terminal').disabled = this.preparing;
+ this.connectButton.classList.toggle('primary', !active);
+ this.connectButton.textContent = this.preparing ? '读取凭据中…'
+ : this.state === 'connecting' ? '取消连接'
+ : this.state === 'connected' ? '断开连接'
+ : this.state === 'disconnecting' ? '正在断开…' : '连接主机';
+ const labels: Record = {
+ idle: '未连接', connecting: '正在连接', connected: 'SSH 已连接', disconnecting: '正在断开', error: '连接失败',
+ };
+ const badge = this.get('#files-connection-state');
+ badge.textContent = labels[this.state];
+ badge.dataset.state = this.state;
+ this.get('#files-host-hint').textContent = active
+ ? '与终端共用会话 · 切换主机前请先断开'
+ : '使用已保存的 SSH 凭据安全连接';
+ }
+
+ private async toggleConnection(): Promise {
+ if (this.state === 'connecting' || this.state === 'connected') {
+ if (!this.confirmLeave()) return;
+ this.actions.disconnect();
+ return;
+ }
+ const host = this.hosts.find((item) => item.id === this.select.value);
+ if (!host || this.preparing || this.state === 'disconnecting') return;
+ this.preparing = true;
+ this.renderConnection();
+ this.setMessage(`正在读取 ${host.name} 的连接凭据…`);
+ try { await this.actions.connect(host); }
+ catch (error) { this.setMessage(error instanceof Error ? error.message : '连接准备失败,请重试。', true); }
+ finally { this.preparing = false; this.renderConnection(); }
+ }
+
+ confirmLeave(): boolean {
+ // 只有正在传输时才打断导航,避免用户无意中取消上传或下载。
+ const progress = this.panel.querySelector('#file-manager-progress')!;
+ return progress.hidden || confirm('文件正在传输,离开或断开连接将取消传输。仍要继续吗?');
+ }
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 6e4019a..bb0199e 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -4,6 +4,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
import { historyKey, historyLabel } from './history';
import { listHosts, hostCredentials, saveHost, removeHost, updateHostSystem, type CloudHost, type Credentials, type HostSystemInfo } from './cloud-api';
import { Dashboard } from './dashboard';
+import { FilePage } from './file-page';
import { resolveConnectionControl, resolveConnectionPanel } from './ui-state';
import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key';
import { FileManager, collectFileManagerElements } from './file-manager';
@@ -370,6 +371,7 @@ function applyLanguage(language: Language, persist = false): void {
let profiles: SavedProfile[] = [];
let dashboard: Dashboard | undefined;
+let filePage: FilePage | undefined;
let hostKeys: Record = {};
let socket: WebSocket | null = null;
let connectionState: ConnectionState = 'idle';
@@ -1065,6 +1067,7 @@ function showFormError(message: string, alternate?: string): void {
currentFormError = messageTranslation(message, alternate);
ui.formError.textContent = localize(currentFormError);
ui.formError.hidden = false;
+ if (document.body.dataset.view === 'files') filePage?.setMessage(localize(currentFormError), true);
}
function toast(message: string, kind: 'info' | 'error' = 'info'): void {
@@ -1079,6 +1082,7 @@ function updateConnectionStatus(message: LocalizedMessage): void {
currentSessionSubtitle = message;
const text = localize(message);
ui.sessionSubtitle.textContent = text;
+ filePage?.setMessage(text);
if (connectionState === 'connecting') {
const btnSpan = ui.connect.querySelector('span:last-child');
if (btnSpan) btnSpan.textContent = text;
@@ -1087,6 +1091,7 @@ function updateConnectionStatus(message: LocalizedMessage): void {
function setState(state: ConnectionState, label?: string): void {
connectionState = state;
+ filePage?.setConnection(state, profiles.find((profile) => passwordContext(profile) === currentTargetKey)?.id, currentTargetLabel);
const stateLabel = label ?? ({
idle: bilingual('离线', 'Offline'),
connecting: bilingual('连接中', 'Connecting'),
@@ -1213,6 +1218,7 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive
});
startTimers();
updateConnectionStatus(messageTranslation(message));
+ filePage?.setMessage('');
event(message, 'ready');
if (currentInitialCommand && !initialCommandSent) {
initialCommandSent = true;
@@ -1224,7 +1230,7 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive
sendTerminalData(`${command}\r`);
}, 120);
}
- terminal.focus();
+ if (document.body.dataset.view === 'workspace') terminal.focus();
}
function sendHostKeyDecision(accept: boolean): void {
@@ -1611,7 +1617,7 @@ async function connect(): Promise {
showFormError(validationError);
return;
}
- dashboard?.openWorkspace();
+ if (document.body.dataset.view !== 'files') dashboard?.openWorkspace();
if (location.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) {
showFormError(bilingual('发送 SSH 凭据前必须使用 HTTPS。', 'HTTPS is required before SSH credentials can be sent.'));
return;
@@ -1622,7 +1628,6 @@ async function connect(): Promise {
const generation = ++connectGeneration;
const abortController = new AbortController();
authorizationAbort = abortController;
- setState('connecting');
ui.terminalEmpty.hidden = true;
resetTerminalForConnection(terminal);
fitTerminal(false);
@@ -1632,11 +1637,13 @@ async function connect(): Promise {
const username = ui.username.value.trim();
currentTargetKey = targetKey(host, port, username);
currentTargetLabel = targetLabel(host, port, username);
+ setState('connecting');
const pinnedKey = ui.fingerprint.value.trim() || hostKeys[currentTargetKey] || '';
currentExpectedFingerprint = pinnedKey;
currentRememberedFingerprint = '';
if (pinnedKey) ui.fingerprint.value = pinnedKey;
- currentInitialCommand = ui.initialCommand.value;
+ // 文件管理不应在隐藏终端里执行用户的初始命令;保存的主机配置仍保持原样。
+ currentInitialCommand = document.body.dataset.view === 'files' ? '' : ui.initialCommand.value;
initialCommandSent = false;
pendingHostKey = null;
awaitingHostKeyDecision = false;
@@ -2217,7 +2224,24 @@ async function initialize(): Promise {
initializeCompatibilityAPI();
initMobileToolbar();
applyURLParameters();
+ // 两个视图共用指纹确认与通知,不能随隐藏的终端容器一起消失。
+ document.body.append(ui.hostKeyDialog, ui.toastRegion);
+ filePage = new FilePage(ui.fileManagerPanel, {
+ connect: async (host) => {
+ const loading = applyProfile(host);
+ const selectionGeneration = historyPasswordLoadGeneration;
+ await loading;
+ // 返回总览会作废凭据读取;即使用户立刻回到文件页,也不能启动已经取消的连接。
+ if (selectionGeneration === historyPasswordLoadGeneration && document.body.dataset.view === 'files') await connect();
+ },
+ disconnect: () => disconnect(),
+ openTerminal: () => {
+ dashboard?.openWorkspace();
+ if (connectionState === 'idle' || connectionState === 'error') setPanelOpen(true);
+ },
+ });
dashboard = new Dashboard({
+ files: filePage,
refresh: async () => {
profiles = await loadProfiles();
renderProfiles();
diff --git a/package-lock.json b/package-lock.json
index ce0b892..c8f40dd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260727.1",
+ "@playwright/test": "^1.63.0",
"@types/node": "^22.20.4",
"flag-icons": "^7.5.0",
"smol-toml": "^1.8.0",
@@ -1167,6 +1168,22 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -1823,6 +1840,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.23.tgz",
diff --git a/package.json b/package.json
index 2d7ac3b..d704082 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"build:web": "vite build --config vite.config.ts",
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit && tsc -p tsconfig.scripts.json --noEmit",
"test": "node --test --experimental-strip-types tests/*.test.ts",
+ "test:browser": "playwright test",
"check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build",
"deploy:validate": "node --experimental-strip-types scripts/deploy.ts --validate-only",
"deploy": "node --experimental-strip-types scripts/deploy.ts"
@@ -24,6 +25,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260727.1",
+ "@playwright/test": "^1.63.0",
"@types/node": "^22.20.4",
"flag-icons": "^7.5.0",
"smol-toml": "^1.8.0",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..d3aa92a
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests/browser',
+ fullyParallel: true,
+ use: {
+ channel: process.env.PLAYWRIGHT_CHANNEL,
+ baseURL: 'http://127.0.0.1:5173',
+ locale: 'zh-CN',
+ screenshot: 'only-on-failure',
+ trace: 'retain-on-failure',
+ },
+ projects: [
+ { name: 'desktop', use: { viewport: { width: 1440, height: 1000 } } },
+ { name: 'mobile', use: { viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true } },
+ ],
+ webServer: {
+ command: 'npm run dev:web -- --host 127.0.0.1',
+ url: 'http://127.0.0.1:5173',
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/tests/browser/file-fixture.ts b/tests/browser/file-fixture.ts
new file mode 100644
index 0000000..f1029f7
--- /dev/null
+++ b/tests/browser/file-fixture.ts
@@ -0,0 +1,113 @@
+import type { Page, WebSocketRoute } from '@playwright/test';
+
+const fingerprint = `SHA256:${'A'.repeat(43)}`;
+const host = (id: string, name: string, address: string) => ({
+ id, name, host: address, port: 22, username: 'root', group: '测试主机',
+ authMethod: 'password', initialCommand: 'echo should-not-run-in-files',
+ termType: 'xterm-256color', encoding: 'utf-8', fingerprint,
+ location: null, system: null, hasCredential: true, updatedAt: Date.now(),
+});
+const entry = (name: string, type = 'file', size = 12) => ({
+ name, type, size, mtime: 1_790_100_000, permissions: type === 'directory' ? '0755' : '0644', owner: 'root', group: 'root',
+});
+
+// 只在测试浏览器内模拟 API/协议,不访问真实主机、不增加生产认证绕过。
+export async function fileFixture(page: Page, options: { firstSeen?: boolean; credentialError?: boolean; noHosts?: boolean; holdUpload?: boolean } = {}) {
+ const hosts = options.noHosts ? [] : [host('alpha', 'Tokyo production', '192.0.2.10'), host('beta', 'Singapore backup', '192.0.2.20')];
+ const directories = new Map([
+ ['/', [entry('root', 'directory')]],
+ ['/root', [entry('backups', 'directory'), entry('deploy', 'directory'), entry('README.md'), entry('nginx.conf', 'file', 2480)]],
+ ['/root/backups', []],
+ ['/root/deploy', []],
+ ]);
+ const calls: Array> = [];
+ const sshSockets: WebSocketRoute[] = [];
+ const sftpSockets: WebSocketRoute[] = [];
+ const uploaded: Buffer[] = [];
+ await page.route('**/api/**', async (route) => {
+ const path = new URL(route.request().url()).pathname;
+ if (path === '/api/auth/me') return route.fulfill({ json: { account: { username: 'Administrator' }, provider: 'cloudflare' } });
+ if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId: crypto.randomUUID() } });
+ if (path.endsWith('/credentials')) {
+ return route.fulfill(options.credentialError
+ ? { status: 500, json: { error: '读取凭据失败,请重试。' } }
+ : { json: { password: 'test-only-password' } });
+ }
+ if (path === '/api/hosts') return route.fulfill({ json: { hosts } });
+ return route.fulfill({ json: { host: hosts.find((item) => path.includes(item.id)) ?? hosts[0] } });
+ });
+ await page.routeWebSocket('**/api/ssh?*', (ws) => {
+ sshSockets.push(ws);
+ const ready = () => {
+ ws.send(JSON.stringify({ type: 'ready' }));
+ ws.send(JSON.stringify({ type: 'sftp_attach', url: '/api/sftp?session=test&token=test' }));
+ };
+ ws.onMessage((raw) => {
+ if (typeof raw !== 'string') { calls.push({ type: 'terminal-input', data: raw.toString() }); return; }
+ const message = JSON.parse(raw);
+ calls.push(message);
+ if (message.type === 'connect') {
+ if (options.firstSeen) {
+ // 返回变化指纹,确保仍需真实 UI 确认,而不是由测试直接放行。
+ ws.send(JSON.stringify({ type: 'host_key', fingerprint: `SHA256:${'B'.repeat(43)}`, expectedFingerprint: fingerprint, keyType: 'ssh-ed25519', trusted: false }));
+ } else ready();
+ }
+ if (message.type === 'host_key_decision' && message.accept) ready();
+ });
+ });
+ await page.routeWebSocket('**/api/sftp?*', (ws) => {
+ sftpSockets.push(ws);
+ let upload: { requestId: string; path: string; size: number } | undefined;
+ const send = (data: object) => ws.send(JSON.stringify(data));
+ const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
+ const name = (path: string) => path.slice(path.lastIndexOf('/') + 1);
+ ws.onMessage((raw) => {
+ if (typeof raw !== 'string') {
+ uploaded.push(Buffer.from(raw));
+ send({ type: 'sftp_upload_progress', requestId: upload!.requestId, loaded: uploaded.reduce((sum, chunk) => sum + chunk.length, 0) });
+ return;
+ }
+ const message = JSON.parse(raw);
+ calls.push(message);
+ const { type, requestId, path } = message;
+ if (type === 'sftp_init') send({ type: 'sftp_ready', cwd: '/root', version: 3 });
+ if (type === 'sftp_list') {
+ if (path === '/forbidden') send({ type: 'sftp_error', requestId, message: 'Permission denied' });
+ else send({ type: 'sftp_list_result', requestId, path, entries: directories.get(path) ?? [] });
+ }
+ if (type === 'sftp_mkdir') {
+ directories.get(parent(path))!.push(entry(name(path), 'directory'));
+ directories.set(path, []);
+ }
+ if (type === 'sftp_rename') {
+ directories.get(parent(message.oldPath))!.find((item) => item.name === name(message.oldPath))!.name = name(message.newPath);
+ }
+ if (type === 'sftp_delete' || type === 'sftp_rmdir') {
+ directories.set(parent(path), directories.get(parent(path))!.filter((item) => item.name !== name(path)));
+ }
+ if (['sftp_mkdir', 'sftp_rename', 'sftp_delete', 'sftp_rmdir'].includes(type)) send({ type: `${type}_result`, requestId });
+ if (type === 'sftp_upload_start') {
+ upload = message;
+ if (!options.holdUpload) send({ type: 'sftp_upload_ready', requestId });
+ }
+ if (type === 'sftp_upload_end') {
+ directories.get(parent(upload!.path))!.push(entry(name(upload!.path), 'file', upload!.size));
+ send({ type: 'sftp_upload_complete', requestId });
+ }
+ if (type === 'sftp_download') {
+ const data = Buffer.from('hello files\n');
+ send({ type: 'sftp_download_start', requestId, size: data.length });
+ ws.send(data);
+ send({ type: 'sftp_download_done', requestId, size: data.length });
+ }
+ });
+ });
+ await page.goto('/');
+ await page.locator('#rail-files').click();
+ return { hosts, calls, sshSockets, sftpSockets, uploaded };
+}
+
+export async function connectFiles(page: Page, id = 'alpha') {
+ await page.locator('#files-host').selectOption(id);
+ await page.locator('#files-connect').click();
+}
diff --git a/tests/browser/files.spec.ts b/tests/browser/files.spec.ts
new file mode 100644
index 0000000..49a3298
--- /dev/null
+++ b/tests/browser/files.spec.ts
@@ -0,0 +1,200 @@
+import { test, expect } from '@playwright/test';
+import { readFile } from 'node:fs/promises';
+import { connectFiles, fileFixture } from './file-fixture';
+
+test('独立入口、空状态与移动端布局', async ({ page }, testInfo) => {
+ await fileFixture(page, { noHosts: true });
+ await expect(page.locator('#files-heading')).toBeVisible();
+ await expect(page.locator('#rail-files')).toHaveAttribute('aria-current', 'page');
+ await expect(page.locator('#files-connect')).toBeDisabled();
+ await expect(page.locator('#file-upload')).toBeDisabled();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ await page.screenshot({ path: testInfo.outputPath('files-empty.png'), fullPage: true });
+ await page.locator('#rail-overview').click();
+ await expect(page.locator('#hosts-heading')).toBeVisible();
+});
+
+test('连接、目录导航与共享终端会话不重复附着', async ({ page }, testInfo) => {
+ const fixture = await fileFixture(page);
+ await connectFiles(page);
+ await expect(page.locator('#file-manager-path')).toHaveValue('/root');
+ await expect(page.locator('#file-table-body')).toContainText('README.md');
+ await expect(page.locator('#files-host')).toBeDisabled();
+ await expect(page.locator('#files-connection-state')).toHaveText('SSH 已连接');
+ await expect(page.locator('.header-add')).toBeHidden();
+ await page.locator('.files-footer').scrollIntoViewIfNeeded();
+ await expect(page.locator('.files-footer')).toBeInViewport();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ await expect(page.locator('#toast-region .toast')).toHaveCount(0);
+ await page.screenshot({ path: testInfo.outputPath('files-connected.png'), fullPage: true });
+ await page.locator('#file-table-body tr').filter({ hasText: 'backups' }).dblclick();
+ await expect(page.locator('#file-manager-path')).toHaveValue('/root/backups');
+ await page.locator('#file-up').click();
+ await expect(page.locator('#file-manager-path')).toHaveValue('/root');
+ await page.locator('#files-terminal').click();
+ await expect(page.locator('#terminal-card')).toBeVisible();
+ await page.locator('#file-manager-tab').click();
+ await expect(page.locator('#app #file-manager-panel')).toBeVisible();
+ await page.getByRole('button', { name: '文件管理', exact: true }).click();
+ await expect(page.locator('.files-page #file-manager-panel')).toBeVisible();
+ await expect(page.locator('#file-manager-path')).toHaveValue('/root');
+ expect(fixture.sshSockets).toHaveLength(1);
+ expect(fixture.sftpSockets).toHaveLength(1);
+ expect(fixture.calls.filter((call) => call.type === 'terminal-input')).toHaveLength(0);
+ await page.locator('#files-terminal').click();
+ await expect(page.locator('#app #file-manager-panel')).toBeVisible();
+ await expect(page.locator('#file-manager-tab')).toHaveAttribute('aria-selected', 'true');
+});
+
+test('复用新建文件夹、重命名和删除协议', async ({ page }) => {
+ const fixture = await fileFixture(page);
+ await connectFiles(page);
+ await expect(page.locator('#file-mkdir')).toBeEnabled();
+ page.once('dialog', (dialog) => dialog.accept('test-folder'));
+ await page.locator('#file-mkdir').click();
+ const folder = page.locator('#file-table-body tr').filter({ hasText: 'test-folder' });
+ await expect(folder).toBeVisible();
+ await folder.click();
+ page.once('dialog', (dialog) => dialog.accept('renamed-folder'));
+ await page.locator('#file-rename').click();
+ const renamed = page.locator('#file-table-body tr').filter({ hasText: 'renamed-folder' });
+ await expect(renamed).toBeVisible();
+ await renamed.click();
+ page.once('dialog', (dialog) => dialog.dismiss());
+ await page.locator('#file-delete').click();
+ await expect(renamed).toBeVisible();
+ page.once('dialog', (dialog) => dialog.accept());
+ await page.locator('#file-delete').click();
+ await expect(renamed).toHaveCount(0);
+ await page.locator('#file-table-body tr').filter({ hasText: 'README.md' }).click();
+ page.once('dialog', (dialog) => dialog.accept());
+ await page.locator('#file-delete').click();
+ await expect(page.locator('#file-table-body')).not.toContainText('README.md');
+ expect(fixture.calls).toEqual(expect.arrayContaining([
+ expect.objectContaining({ type: 'sftp_mkdir', path: '/root/test-folder' }),
+ expect.objectContaining({ type: 'sftp_rename', oldPath: '/root/test-folder', newPath: '/root/renamed-folder' }),
+ expect.objectContaining({ type: 'sftp_rmdir', path: '/root/renamed-folder' }),
+ expect.objectContaining({ type: 'sftp_delete', path: '/root/README.md' }),
+ ]));
+});
+
+test('上传与下载校验实际字节', async ({ page }) => {
+ const fixture = await fileFixture(page);
+ await connectFiles(page);
+ await expect(page.locator('#file-upload')).toBeEnabled();
+ const payload = Buffer.from('file manager upload regression\n');
+ await page.locator('#file-upload-input').setInputFiles({ name: 'upload.txt', mimeType: 'text/plain', buffer: payload });
+ await expect(page.locator('#file-table-body')).toContainText('upload.txt');
+ expect(Buffer.concat(fixture.uploaded)).toEqual(payload);
+ await page.locator('#file-table-body tr').filter({ hasText: 'README.md' }).click();
+ const downloading = page.waitForEvent('download');
+ await page.locator('#file-download').click();
+ const download = await downloading;
+ expect(download.suggestedFilename()).toBe('README.md');
+ expect(await readFile((await download.path())!, 'utf8')).toBe('hello files\n');
+});
+
+test('更换主机清理旧会话,文件页继续可操作', async ({ page }) => {
+ const fixture = await fileFixture(page);
+ await connectFiles(page);
+ await expect(page.locator('#file-upload')).toBeEnabled();
+ await page.locator('#files-connect').click();
+ await expect(page.locator('#file-upload')).toBeDisabled();
+ await expect(page.locator('#files-host')).toBeEnabled();
+ await connectFiles(page, 'beta');
+ await expect(page.locator('#file-upload')).toBeEnabled();
+ expect(fixture.calls.filter((call) => call.type === 'connect').map((call) => call.host)).toEqual(['192.0.2.10', '192.0.2.20']);
+ expect(fixture.calls.filter((call) => call.type === 'sftp_close')).toHaveLength(1);
+});
+
+test('文件页仍需指纹确认', async ({ page }) => {
+ const fixture = await fileFixture(page, { firstSeen: true });
+ await connectFiles(page);
+ await expect(page.locator('#host-key-dialog')).toBeVisible();
+ await expect(page.locator('#file-upload')).toBeDisabled();
+ expect(fixture.sftpSockets).toHaveLength(0);
+ await page.locator('#accept-host-key').click();
+ await expect(page.locator('#file-upload')).toBeEnabled();
+});
+
+test('凭据和目录错误显示在文件页', async ({ page }) => {
+ await fileFixture(page, { credentialError: true });
+ await connectFiles(page);
+ await expect(page.locator('#files-notice')).toContainText('读取凭据失败');
+ await expect(page.locator('#files-connect')).toBeEnabled();
+});
+
+test('目录权限错误可恢复', async ({ page }) => {
+ await fileFixture(page);
+ await connectFiles(page);
+ await expect(page.locator('#file-upload')).toBeEnabled();
+ await page.locator('#file-manager-path').fill('/forbidden');
+ await page.locator('#file-manager-path').press('Enter');
+ await expect(page.locator('#file-manager-error')).toBeVisible();
+ await page.locator('#file-home').click();
+ await expect(page.locator('#file-table-body')).toContainText('README.md');
+ await expect(page.locator('#file-manager-error')).toBeHidden();
+});
+
+test('传输时离开需确认,切换终端不打断传输', async ({ page }) => {
+ const fixture = await fileFixture(page, { holdUpload: true });
+ await connectFiles(page);
+ await expect(page.locator('#file-upload')).toBeEnabled();
+ await page.locator('#file-upload-input').setInputFiles({ name: 'pending.txt', mimeType: 'text/plain', buffer: Buffer.from('pending') });
+ await expect(page.locator('#file-manager-progress')).toBeVisible();
+ page.once('dialog', (dialog) => dialog.dismiss());
+ await page.locator('#rail-overview').click();
+ await expect(page.locator('#files-heading')).toBeVisible();
+ await page.locator('#files-terminal').click();
+ await page.getByRole('button', { name: '文件管理', exact: true }).click();
+ await expect(page.locator('#file-manager-progress')).toBeVisible();
+ expect(fixture.sftpSockets).toHaveLength(1);
+ page.once('dialog', (dialog) => dialog.accept());
+ await page.locator('#files-connect').click();
+ await expect(page.locator('#file-manager-progress')).toBeHidden();
+});
+
+test('读取凭据期间禁止切换终端,避免无反馈取消连接', async ({ page }) => {
+ await fileFixture(page);
+ let finish!: () => void;
+ const pending = new Promise((resolve) => { finish = resolve; });
+ await page.route('**/api/hosts/alpha/credentials', async (route) => {
+ await pending;
+ await route.fulfill({ json: { password: 'test-only' } });
+ });
+ await connectFiles(page);
+ await expect(page.locator('#files-connect')).toHaveText('读取凭据中…');
+ await expect(page.locator('#files-terminal')).toBeDisabled();
+ finish();
+ await expect(page.locator('#file-upload')).toBeEnabled();
+ await expect(page.locator('#files-terminal')).toBeEnabled();
+});
+
+test('离开再返回文件页,迟到的凭据不会启动已取消会话', async ({ page }) => {
+ const fixture = await fileFixture(page);
+ let finish!: () => void;
+ const pending = new Promise((resolve) => { finish = resolve; });
+ await page.route('**/api/hosts/alpha/credentials', async (route) => {
+ await pending;
+ await route.fulfill({ json: { password: 'test-only' } });
+ });
+ await connectFiles(page);
+ await expect(page.locator('#files-connect')).toHaveText('读取凭据中…');
+ await page.locator('#rail-overview').click();
+ await expect(page.locator('#hosts-heading')).toBeVisible();
+ await page.locator('#rail-files').click();
+ finish();
+ await expect(page.locator('#files-connect')).toHaveText('连接主机');
+ expect(fixture.sshSockets).toHaveLength(0);
+});
+
+test('会话授权失败后可重试,文件操作保持禁用', async ({ page }) => {
+ await fileFixture(page);
+ await page.route('**/api/session', (route) => route.fulfill({ status: 403, json: { error: '会话授权失败,请重新登录。' } }));
+ await connectFiles(page);
+ await expect(page.locator('#files-notice')).toContainText('会话授权失败');
+ await expect(page.locator('#files-connection-state')).toHaveText('连接失败');
+ await expect(page.locator('#files-connect')).toBeEnabled();
+ await expect(page.locator('#file-upload')).toBeDisabled();
+ await expect(page.locator('#files-host')).toBeEnabled();
+});