feat(files): add fullscreen file manager

为文件管理添加全屏模式,保留目录与共享连接状态。
This commit is contained in:
gdz66601 committed 2026-10-03 16:43:44 +08:00
1 parent 4efa997ad1
commit 645ed777b4
7 files changed
+125 -1

No files matched your search

+4
View File
@@ -470,6 +470,10 @@
<button id="file-delete" class="file-action file-action-wide danger" type="button" disabled data-i18n-zh="删除" data-i18n-en="Delete">删除</button> <button id="file-delete" class="file-action file-action-wide danger" type="button" disabled data-i18n-zh="删除" data-i18n-en="Delete">删除</button>
<input id="file-upload-input" class="sr-only" type="file" tabindex="-1" /> <input id="file-upload-input" class="sr-only" type="file" tabindex="-1" />
</div> </div>
<div class="file-view-controls">
<button id="fullscreen-files" class="file-action" type="button" disabled aria-label="放大文件管理" title="放大文件管理" data-i18n-aria-label-zh="放大文件管理" data-i18n-aria-label-en="Expand file manager" data-i18n-title-zh="放大文件管理" data-i18n-title-en="Expand file manager"></button>
<button id="exit-fullscreen-files" class="file-action" type="button" aria-label="退出全屏" title="退出全屏" data-i18n-aria-label-zh="退出全屏" data-i18n-aria-label-en="Exit fullscreen" data-i18n-title-zh="退出全屏" data-i18n-title-en="Exit fullscreen"></button>
</div>
</div> </div>
<div class="file-table-wrap"> <div class="file-table-wrap">
+3
View File
@@ -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-path-field input { height: 40px; font-size: 13px; padding-inline: 12px; }
.files-page .file-actions { flex: 1; flex-wrap: wrap; gap: 6px; overflow: visible; } .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 { 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-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-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 #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-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-statusbar { min-height: 42px; padding: 8px 16px; font-size: 11px; flex-wrap: wrap; }
.files-page .file-progress { max-width: 100%; } .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); } #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; } .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'] #dashboard .home-search, body[data-view='files'] #dashboard .header-add { display: none; }
+20
View File
@@ -1,6 +1,7 @@
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import { createElement, Maximize, Minimize } from 'lucide';
import { historyKey, historyLabel } from './history'; import { historyKey, historyLabel } from './history';
import { listHosts, hostCredentials, saveHost, removeHost, updateHostSystem, type CloudHost, type Credentials, type HostSystemInfo } from './cloud-api'; import { listHosts, hostCredentials, saveHost, removeHost, updateHostSystem, type CloudHost, type Credentials, type HostSystemInfo } from './cloud-api';
import { Dashboard } from './dashboard'; import { Dashboard } from './dashboard';
@@ -295,6 +296,8 @@ const ui = {
eventMessage: element<HTMLElement>('event-message'), eventMessage: element<HTMLElement>('event-message'),
fileManagerTab: element<HTMLButtonElement>('file-manager-tab'), fileManagerTab: element<HTMLButtonElement>('file-manager-tab'),
fileManagerPanel: element<HTMLElement>('file-manager-panel'), fileManagerPanel: element<HTMLElement>('file-manager-panel'),
fullscreenFiles: element<HTMLButtonElement>('fullscreen-files'),
exitFullscreenFiles: element<HTMLButtonElement>('exit-fullscreen-files'),
fileTree: element<HTMLElement>('file-tree'), fileTree: element<HTMLElement>('file-tree'),
processManagerTab: element<HTMLButtonElement>('process-manager-tab'), processManagerTab: element<HTMLButtonElement>('process-manager-tab'),
processManagerPanel: element<HTMLElement>('process-manager-panel'), processManagerPanel: element<HTMLElement>('process-manager-panel'),
@@ -1103,6 +1106,7 @@ function updateConnectionStatus(message: LocalizedMessage): void {
function setState(state: ConnectionState, label?: string): void { function setState(state: ConnectionState, label?: string): void {
connectionState = state; connectionState = state;
ui.fullscreenFiles.disabled = state !== 'connected';
filePage?.setConnection(state, profiles.find((profile) => passwordContext(profile) === currentTargetKey)?.id, currentTargetLabel); filePage?.setConnection(state, profiles.find((profile) => passwordContext(profile) === currentTargetKey)?.id, currentTargetLabel);
const stateLabel = label ?? ({ const stateLabel = label ?? ({
idle: bilingual('离线', 'Offline'), idle: bilingual('离线', 'Offline'),
@@ -2069,6 +2073,22 @@ ui.fullscreenTerminal.addEventListener('click', async () => {
else await ui.terminalCard.requestFullscreen(); else await ui.terminalCard.requestFullscreen();
}); });
document.addEventListener('fullscreenchange', () => fitTerminal(true)); 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.fileManagerTab.addEventListener('click', () => toggleWorkspaceTab('files'));
ui.processManagerTab.addEventListener('click', () => toggleWorkspaceTab('processes')); ui.processManagerTab.addEventListener('click', () => toggleWorkspaceTab('processes'));
ui.eventToggle.addEventListener('click', () => toggleWorkspaceTab('log')); ui.eventToggle.addEventListener('click', () => toggleWorkspaceTab('log'));
+35 -1
View File
@@ -2185,6 +2185,28 @@ button:disabled {
font-size: 9px; 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) { .file-action:hover:not(:disabled) {
border-color: var(--border); border-color: var(--border);
background: var(--surface-2); background: var(--surface-2);
@@ -2421,6 +2443,14 @@ button:disabled {
border-radius: 0; 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 { .toast-region {
position: fixed; position: fixed;
top: calc(var(--topbar-height) + 12px); top: calc(var(--topbar-height) + 12px);
@@ -2915,7 +2945,7 @@ button:disabled {
.file-toolbar { .file-toolbar {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto;
gap: 3px; gap: 3px;
padding: 3px 5px; padding: 3px 5px;
} }
@@ -2945,8 +2975,12 @@ button:disabled {
.file-actions { .file-actions {
width: 100%; width: 100%;
grid-column: 1 / -1;
grid-row: 2;
} }
.file-view-controls { grid-column: 2; grid-row: 1; }
.process-table { .process-table {
min-width: 650px; min-width: 650px;
} }
+7
View File
@@ -12,6 +12,7 @@
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"jose": "^6.2.12", "jose": "^6.2.12",
"lucide": "^1.51.0",
"mappo": "^0.3.0", "mappo": "^0.3.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-arborist": "^3.16.0", "react-arborist": "^3.16.0",
@@ -1857,6 +1858,12 @@
"node": ">=6" "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": { "node_modules/mappo": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/mappo/-/mappo-0.3.0.tgz", "resolved": "https://registry.npmjs.org/mappo/-/mappo-0.3.0.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"jose": "^6.2.12", "jose": "^6.2.12",
"lucide": "^1.51.0",
"mappo": "^0.3.0", "mappo": "^0.3.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-arborist": "^3.16.0", "react-arborist": "^3.16.0",
+55
View File
@@ -8,12 +8,67 @@ test('独立入口、空状态与移动端布局', async ({ page }, testInfo) =>
await expect(page.locator('#rail-files')).toHaveAttribute('aria-current', 'page'); await expect(page.locator('#rail-files')).toHaveAttribute('aria-current', 'page');
await expect(page.locator('#files-connect')).toBeDisabled(); await expect(page.locator('#files-connect')).toBeDisabled();
await expect(page.locator('#file-upload')).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); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('files-empty.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('files-empty.png'), fullPage: true });
await page.locator('#rail-overview').click(); await page.locator('#rail-overview').click();
await expect(page.locator('#hosts-heading')).toBeVisible(); 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) => { test('连接、目录导航与共享终端会话不重复附着', async ({ page }, testInfo) => {
const fixture = await fileFixture(page); const fixture = await fileFixture(page);
await connectFiles(page); await connectFiles(page);