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);