diff --git a/frontend/index.html b/frontend/index.html
index 5d32dc4..f8110d1 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -470,6 +470,10 @@
+
+
+
+
diff --git a/frontend/src/file-page.css b/frontend/src/file-page.css
index f397144..45669f3 100644
--- a/frontend/src/file-page.css
+++ b/frontend/src/file-page.css
@@ -48,6 +48,7 @@ body[data-view='files'] { height: auto; min-height: 100%; overflow-y: auto; back
.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-view-controls .file-action { min-width: 44px; height: 44px; }
#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; }
@@ -68,6 +69,8 @@ body[data-view='files'] { height: auto; min-height: 100%; overflow-y: auto; back
.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%; }
+.files-page .file-manager:fullscreen .file-toolbar { gap: 8px; padding: 8px 12px; }
+.files-page .file-manager:fullscreen .file-path-field { flex-basis: 260px; }
#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; }
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index cb7fcdb..ddf15f4 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,6 +1,7 @@
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
+import { createElement, Maximize, Minimize } from 'lucide';
import { historyKey, historyLabel } from './history';
import { listHosts, hostCredentials, saveHost, removeHost, updateHostSystem, type CloudHost, type Credentials, type HostSystemInfo } from './cloud-api';
import { Dashboard } from './dashboard';
@@ -295,6 +296,8 @@ const ui = {
eventMessage: element('event-message'),
fileManagerTab: element('file-manager-tab'),
fileManagerPanel: element('file-manager-panel'),
+ fullscreenFiles: element('fullscreen-files'),
+ exitFullscreenFiles: element('exit-fullscreen-files'),
fileTree: element('file-tree'),
processManagerTab: element('process-manager-tab'),
processManagerPanel: element('process-manager-panel'),
@@ -1103,6 +1106,7 @@ function updateConnectionStatus(message: LocalizedMessage): void {
function setState(state: ConnectionState, label?: string): void {
connectionState = state;
+ ui.fullscreenFiles.disabled = state !== 'connected';
filePage?.setConnection(state, profiles.find((profile) => passwordContext(profile) === currentTargetKey)?.id, currentTargetLabel);
const stateLabel = label ?? ({
idle: bilingual('离线', 'Offline'),
@@ -2069,6 +2073,22 @@ ui.fullscreenTerminal.addEventListener('click', async () => {
else await ui.terminalCard.requestFullscreen();
});
document.addEventListener('fullscreenchange', () => fitTerminal(true));
+ui.fullscreenFiles.append(createElement(Maximize, { 'aria-hidden': 'true' }));
+ui.exitFullscreenFiles.append(createElement(Minimize, { 'aria-hidden': 'true' }));
+ui.fullscreenFiles.addEventListener('click', () => {
+ // 全屏共享面板而非整个页面,保留目录及传输状态,同时移除周围的连接与导航区域。
+ void ui.fileManagerPanel.requestFullscreen().catch(() => {
+ toast(bilingual('无法进入全屏,请检查浏览器的全屏权限。', 'Unable to enter fullscreen. Check your browser fullscreen permissions.'), 'error');
+ });
+});
+ui.exitFullscreenFiles.addEventListener('click', () => void document.exitFullscreen());
+let filesFullscreen = false;
+document.addEventListener('fullscreenchange', () => {
+ const active = document.fullscreenElement === ui.fileManagerPanel;
+ if (active) ui.exitFullscreenFiles.focus();
+ else if (filesFullscreen) ui.fullscreenFiles.focus();
+ filesFullscreen = active;
+});
ui.fileManagerTab.addEventListener('click', () => toggleWorkspaceTab('files'));
ui.processManagerTab.addEventListener('click', () => toggleWorkspaceTab('processes'));
ui.eventToggle.addEventListener('click', () => toggleWorkspaceTab('log'));
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 54f111c..2a789b1 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -2185,6 +2185,28 @@ button:disabled {
font-size: 9px;
}
+.file-view-controls {
+ display: flex;
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+
+.file-view-controls .file-action {
+ width: 44px;
+ height: 44px;
+ padding: 0;
+}
+
+.file-view-controls svg { width: 18px; height: 18px; }
+.file-view-controls #exit-fullscreen-files { display: none; }
+.file-manager:fullscreen #fullscreen-files { display: none; }
+.file-manager:fullscreen #exit-fullscreen-files { display: grid; }
+
+.file-view-controls .file-action:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
.file-action:hover:not(:disabled) {
border-color: var(--border);
background: var(--surface-2);
@@ -2421,6 +2443,14 @@ button:disabled {
border-radius: 0;
}
+.file-manager:fullscreen {
+ width: 100%;
+ height: 100dvh;
+ border: 0;
+ border-radius: 0;
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
+}
+
.toast-region {
position: fixed;
top: calc(var(--topbar-height) + 12px);
@@ -2915,7 +2945,7 @@ button:disabled {
.file-toolbar {
display: grid;
- grid-template-columns: minmax(0, 1fr);
+ grid-template-columns: minmax(0, 1fr) auto;
gap: 3px;
padding: 3px 5px;
}
@@ -2945,8 +2975,12 @@ button:disabled {
.file-actions {
width: 100%;
+ grid-column: 1 / -1;
+ grid-row: 2;
}
+ .file-view-controls { grid-column: 2; grid-row: 1; }
+
.process-table {
min-width: 650px;
}
diff --git a/package-lock.json b/package-lock.json
index f93b067..c4fa970 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"jose": "^6.2.12",
+ "lucide": "^1.51.0",
"mappo": "^0.3.0",
"react": "^19.3.0",
"react-arborist": "^3.16.0",
@@ -1857,6 +1858,12 @@
"node": ">=6"
}
},
+ "node_modules/lucide": {
+ "version": "1.51.0",
+ "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.51.0.tgz",
+ "integrity": "sha512-7K2Zm6Haz2EhYOAAE1ZQfsfs83ywGodSCvwr7YvCBzB9oelqDsRvMFUBDbXEGUaUMAisC7xcjMmSrozzYL/UwQ==",
+ "license": "ISC"
+ },
"node_modules/mappo": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/mappo/-/mappo-0.3.0.tgz",
diff --git a/package.json b/package.json
index c5c0c93..48a348c 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"jose": "^6.2.12",
+ "lucide": "^1.51.0",
"mappo": "^0.3.0",
"react": "^19.3.0",
"react-arborist": "^3.16.0",
diff --git a/tests/browser/files.spec.ts b/tests/browser/files.spec.ts
index 2f889bc..2a41b09 100644
--- a/tests/browser/files.spec.ts
+++ b/tests/browser/files.spec.ts
@@ -8,12 +8,67 @@ test('独立入口、空状态与移动端布局', async ({ page }, testInfo) =>
await expect(page.locator('#rail-files')).toHaveAttribute('aria-current', 'page');
await expect(page.locator('#files-connect')).toBeDisabled();
await expect(page.locator('#file-upload')).toBeDisabled();
+ await expect(page.locator('#fullscreen-files')).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 errors: string[] = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ const fixture = await fileFixture(page);
+ await expect(page.locator('#fullscreen-files')).toBeDisabled();
+ await connectFiles(page);
+ await expect(page.locator('#file-manager-path')).toHaveValue('/root');
+ await page.locator('.files-list').getByRole('treeitem', { name: 'README.md', exact: true }).click();
+
+ for (const view of ['files', 'workspace']) {
+ const panel = page.locator('#file-manager-panel');
+ const before = await panel.boundingBox();
+ await page.locator('#fullscreen-files').click();
+ await expect.poll(() => page.evaluate(() => document.fullscreenElement?.id)).toBe('file-manager-panel');
+ await expect(page.locator('#fullscreen-files')).toBeHidden();
+ await expect(page.locator('#exit-fullscreen-files')).toBeVisible();
+ await expect(page.locator('#exit-fullscreen-files')).toBeFocused();
+ const exitBounds = await page.locator('#exit-fullscreen-files').boundingBox();
+ expect(exitBounds!.width).toBeGreaterThanOrEqual(44);
+ expect(exitBounds!.height).toBeGreaterThanOrEqual(44);
+ await expect(page.locator('#file-download')).toBeEnabled();
+ await expect(page.locator('#file-manager-path')).toHaveValue('/root');
+ const bounds = await panel.boundingBox();
+ const viewport = await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
+ expect(bounds!.x).toBe(0);
+ expect(bounds!.y).toBe(0);
+ expect(bounds!.width).toBe(viewport.width);
+ expect(bounds!.height).toBe(viewport.height);
+ expect(bounds!.height).toBeGreaterThan(before!.height);
+ expect(await panel.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
+ const table = await panel.locator('.file-table-wrap').boundingBox();
+ expect(table!.height).toBeGreaterThan(viewport.height / 2);
+ await page.screenshot({ path: testInfo.outputPath(`files-fullscreen-${view}.png`) });
+ await page.locator('#exit-fullscreen-files').click();
+ await expect.poll(() => page.evaluate(() => document.fullscreenElement)).toBeNull();
+ await expect(page.locator('#fullscreen-files')).toBeFocused();
+ await expect(page.locator('#exit-fullscreen-files')).toBeHidden();
+ await expect(panel).toBeVisible();
+ await expect(page.locator('body')).toHaveAttribute('data-view', view);
+ if (view === 'files') {
+ await expect(page.locator('#files-connection-state')).toHaveText('SSH 已连接');
+ await page.locator('#files-terminal').click();
+ await page.locator('#file-manager-tab').click();
+ }
+ }
+
+ expect(fixture.sshSockets).toHaveLength(1);
+ expect(fixture.sftpSockets).toHaveLength(1);
+ expect(errors).toEqual([]);
+ await page.getByRole('button', { name: '文件管理', exact: true }).click();
+ await page.locator('#files-connect').click();
+ await expect(page.locator('#fullscreen-files')).toBeDisabled();
+});
+
test('连接、目录导航与共享终端会话不重复附着', async ({ page }, testInfo) => {
const fixture = await fileFixture(page);
await connectFiles(page);