feat(files): add fullscreen file manager
为文件管理添加全屏模式,保留目录与共享连接状态。
This commit is contained in:
1 parent
4efa997ad1
commit
645ed777b4
7 files changed
+125
-1
No files matched your search
@@ -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">
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+7
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user