diff --git a/docs/changelog/2026-06-02-files-refactor-P1.md b/docs/changelog/2026-06-02-files-refactor-P1.md new file mode 100644 index 00000000..f60220d0 --- /dev/null +++ b/docs/changelog/2026-06-02-files-refactor-P1.md @@ -0,0 +1,57 @@ +# Changelog: 文件管理器 P1 — Composable 拆分 + 目录缓存 + +**日期**: 2026-06-02 +**类型**: 重构 (Refactor) +**影响范围**: `frontend/src/pages/FilesPage.vue`、`frontend/src/stores/files.ts`、`frontend/src/composables/files/*` + +--- + +## 变更摘要 + +按 `docs/design/specs/2026-06-02-files-refactor-design.md` 完成 P1:将 1500+ 行 `FilesPage.vue` 业务逻辑拆分为 Pinia store 与域 composable;页面 script 约 110 行,仅负责装配与模板绑定。 + +--- + +## 动机 + +- 单文件难以维护,三处 `browse()` 入口难追踪 +- 为 P2 组件拆分、P3 缓存、P4 REST API 打基础 + +--- + +## 主要改动 + +| 模块 | 说明 | +|------|------| +| `stores/files.ts` | 服务器/路径/列表/权限状态;5s TTL 目录缓存(LRU 50 项) | +| `useFilesBrowse.ts` | 浏览、路由同步、序号与 in-flight 去重 | +| `useFilesNavigation.ts` | 面包屑、初始化、路由 watch、服务器切换 | +| `useFilesPermissions.ts` | capability、权限横幅、一键 sudo | +| `useFilesActions.ts` | 上传/删除/重命名/压缩/剪贴板等 | +| `useFilesEditor.ts` | 预览、Monaco 编辑 | +| `useFilesPage.ts` | 页面级装配入口 | +| `FilesPage.vue` | 模板不变,script 瘦身为 `useFilesPage()` | + +--- + +## 行为变化 + +- **重复进入同一目录(5s 内)**:命中内存缓存,0 次 `/sync/browse` +- **写操作后**:`invalidateCache` 强制下次 `browse({ force: true })` +- 其余 UX 与 P0 后一致 + +--- + +## 是否需迁移 / 重启 + +- 仅前端重新构建部署 +- 后端无变更 + +--- + +## 验证方式 + +1. `cd frontend && npx vite build` +2. 部署后 `prune_frontend_assets.py` +3. 文件管理:切换服务器、进目录、上传/删除/编辑/粘贴/sudo 配置 +4. Network:同目录 5s 内二次进入无 browse 请求 diff --git a/docs/design/plans/2026-06-02-files-refactor-P1.md b/docs/design/plans/2026-06-02-files-refactor-P1.md new file mode 100644 index 00000000..009db47a --- /dev/null +++ b/docs/design/plans/2026-06-02-files-refactor-P1.md @@ -0,0 +1,35 @@ +# 文件管理器重构 P1 — 拆分 Composable + +**日期**: 2026-06-02 +**设计**: `docs/design/specs/2026-06-02-files-refactor-design.md` §3 P1 +**前置**: P0 JWT 主动续期(已完成) + +## 目标 + +- 将 `FilesPage.vue` 业务逻辑迁入 `stores/files.ts` + `composables/files/*` +- `FilesPage.vue` 仅保留模板、筛选 UI 状态、组合 composable(目标 script < 400 行,P2 再压至 ≤250) +- 引入 Pinia 单一真相 + 5s 目录缓存(P3 子集) + +## 文件清单 + +| 路径 | 职责 | +|------|------| +| `frontend/src/stores/files.ts` | 服务器/路径/列表/loading/权限/缓存 | +| `frontend/src/composables/files/constants.ts` | sessionStorage 键 | +| `frontend/src/composables/files/useFilesBrowse.ts` | browse、错误文案、emptyHint | +| `frontend/src/composables/files/useFilesNavigation.ts` | 路由同步、面包屑、初始化、watch | +| `frontend/src/composables/files/useFilesPermissions.ts` | capability、横幅、一键 sudo | +| `frontend/src/composables/files/useFilesEditor.ts` | 预览/编辑/保存回调 | +| `frontend/src/composables/files/useFilesActions.ts` | 上传/删除/重命名/压缩等 | +| `frontend/src/composables/files/index.ts` | 桶导出 | +| `frontend/src/pages/FilesPage.vue` | 瘦身后页面 | + +## 测试要点 + +- `npx vite build` 通过 +- 进目录仍 ≤1 次 `/sync/browse`(缓存命中 0 次) +- 切换服务器、面包屑、粘贴、编辑、sudo 一键配置行为不变 + +## 回滚 + +`git revert` P1 commit;`FilesPage.vue` 单文件可独立回退。 diff --git a/frontend/src/composables/files/constants.ts b/frontend/src/composables/files/constants.ts new file mode 100644 index 00000000..7bd0b147 --- /dev/null +++ b/frontend/src/composables/files/constants.ts @@ -0,0 +1,5 @@ +export const FILES_LAST_SERVER_KEY = 'nexus:files:last_server_id' +export const FILES_HINT_DISMISSED_KEY = 'nexus:files-hint-dismissed' + +export const FILES_CACHE_TTL_MS = 5000 +export const FILES_CACHE_MAX_ENTRIES = 50 diff --git a/frontend/src/composables/files/index.ts b/frontend/src/composables/files/index.ts new file mode 100644 index 00000000..11476447 --- /dev/null +++ b/frontend/src/composables/files/index.ts @@ -0,0 +1,7 @@ +export { useFilesBrowse, isNotFoundError, friendlyBrowseError } from './useFilesBrowse' +export { useFilesNavigation } from './useFilesNavigation' +export { useFilesPermissions } from './useFilesPermissions' +export { useFilesEditor } from './useFilesEditor' +export { useFilesActions } from './useFilesActions' +export { useFilesSelection } from './useFilesSelection' +export { useFilesPage } from './useFilesPage' diff --git a/frontend/src/composables/files/useFilesActions.ts b/frontend/src/composables/files/useFilesActions.ts new file mode 100644 index 00000000..28fc3001 --- /dev/null +++ b/frontend/src/composables/files/useFilesActions.ts @@ -0,0 +1,601 @@ +import { computed, nextTick, ref, type Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { http } from '@/api' +import { useFilesClipboard } from '@/composables/useFilesClipboard' +import { useSnackbar } from '@/composables/useSnackbar' +import { useFilesStore } from '@/stores/files' +import type { FileEntry } from '@/types/api' +import { isArchiveFile } from '@/utils/fileSort' +import { saveBlobAsFile } from '@/utils/fileDownload' +import { invalidateCachedFile } from '@/utils/filePreload' +import { + joinRemotePath, + normalizeRemotePath, + validatePathSegment, +} from '@/utils/remotePath' +import type { useFilesSelection } from './useFilesSelection' + +type SelectionApi = ReturnType + +export function useFilesActions(options: { + browse: (opts?: { force?: boolean }) => Promise + invalidateAfterMutation: (parentPath?: string) => void + selection: SelectionApi + openEntry: (item: FileEntry) => void + editFile: (item: FileEntry) => Promise + previewFile: (item: FileEntry) => Promise + openInTerminal: () => void + editorWorkbench: Ref<{ openFile: (p: { path: string; name: string; content: string }) => void } | null> + actionLoading?: Ref +}) { + const snackbar = useSnackbar() + const store = useFilesStore() + const { selectedServer, currentPath } = storeToRefs(store) + const { selectedFiles, selectedAbsolutePaths, clearSelection } = options.selection + const { + setFromSelection, + canPasteOnServer, + pasteToDirectory, + } = useFilesClipboard() + + const dropActive = ref(false) + const showUpload = ref(false) + const showNewFile = ref(false) + const newFileName = ref('') + const showMkdir = ref(false) + const showCompress = ref(false) + const compressDest = ref('') + const compressFormat = ref<'tar.gz' | 'zip'>('tar.gz') + const compressPaths = ref([]) + const showDecompress = ref(false) + const decompressSource = ref('') + const decompressDest = ref('.') + const showFileDelete = ref(false) + const uploadFiles = ref([]) + const uploading = ref(false) + const mkdirName = ref('') + const actionLoading = options.actionLoading ?? ref(false) + const deletingFile = ref(null) + const showRename = ref(false) + const newName = ref('') + const renamingFile = ref(null) + const showChmod = ref(false) + const chmodLoading = ref(false) + const chmodFile = ref(null) + const showContextMenu = ref(false) + const contextX = ref(0) + const contextY = ref(0) + const contextFile = ref(null) + + function resolvePasteDestination(explicitDest?: string): string { + if (explicitDest) return normalizeRemotePath(explicitDest) + const dirs = selectedFiles.value.filter((f) => f.type === 'directory') + if (dirs.length === 1 && selectedFiles.value.length === 1) { + return joinRemotePath(currentPath.value, dirs[0].name) + } + return normalizeRemotePath(currentPath.value) + } + + const pasteDestLabel = computed(() => { + const dest = resolvePasteDestination() + const cur = normalizeRemotePath(currentPath.value) + return dest === cur ? '当前目录' : dest + }) + + function copyToClipboard(items?: FileEntry[]) { + if (!selectedServer.value) return + const paths = selectedAbsolutePaths(items) + if (!paths.length) { + snackbar('请先选择文件或目录', 'warning') + return + } + if (setFromSelection('copy', selectedServer.value, paths)) { + snackbar(`已复制 ${paths.length} 项`) + } + } + + function cutToClipboard(items?: FileEntry[]) { + if (!selectedServer.value) return + const paths = selectedAbsolutePaths(items) + if (!paths.length) { + snackbar('请先选择文件或目录', 'warning') + return + } + if (setFromSelection('cut', selectedServer.value, paths)) { + snackbar(`已剪切 ${paths.length} 项`) + } + } + + async function pasteClipboard(explicitDest?: string) { + if (!selectedServer.value) { + snackbar('请先选择服务器', 'warning') + return + } + if (!canPasteOnServer(selectedServer.value)) { + snackbar('剪贴板为空或服务器不一致', 'warning') + return + } + const targetDir = resolvePasteDestination(explicitDest) + actionLoading.value = true + try { + await pasteToDirectory(targetDir) + clearSelection() + options.invalidateAfterMutation(targetDir) + await options.browse({ force: true }) + snackbar(`已粘贴到 ${targetDir}`) + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '粘贴失败' + snackbar(msg, 'error') + } finally { + actionLoading.value = false + } + } + + async function onDropFiles(e: DragEvent) { + dropActive.value = false + if (!selectedServer.value) { + snackbar('请先选择服务器', 'warning') + return + } + const dropped = e.dataTransfer?.files + if (!dropped?.length) return + uploadFiles.value = Array.from(dropped) + await doUpload() + } + + function contextAction(action: string) { + showContextMenu.value = false + if (!contextFile.value) return + const item = contextFile.value + switch (action) { + case 'open': + options.openEntry(item) + break + case 'copy': + copyToClipboard([item]) + break + case 'cut': + cutToClipboard([item]) + break + case 'paste': + void pasteClipboard() + break + case 'pasteInto': + if (item.type === 'directory') { + void pasteClipboard(joinRemotePath(currentPath.value, item.name)) + } + break + case 'edit': + void options.editFile(item) + break + case 'preview': + void options.previewFile(item) + break + case 'download': + void downloadFile(item) + break + case 'copyPath': + void copyRemotePath(item) + break + case 'terminal': + options.openInTerminal() + break + case 'compress': + openCompressDialog([item]) + break + case 'decompress': + openDecompressDialog(item) + break + case 'rename': + startRename(item) + break + case 'chmod': + startChmod(item) + break + case 'delete': + void confirmDeleteFile(item) + break + } + } + + async function copyRemotePath(item: FileEntry) { + const path = joinRemotePath(currentPath.value, item.name) + try { + await navigator.clipboard.writeText(path) + snackbar('路径已复制') + } catch { + snackbar('复制失败', 'error') + } + } + + function openCompressDialog(items?: FileEntry[]) { + if (!selectedServer.value) return + const targets = items?.length + ? items + : selectedFiles.value.length + ? selectedFiles.value + : [] + if (!targets.length) { + snackbar('请选择要压缩的文件或目录', 'warning') + return + } + compressPaths.value = targets.map((f) => + joinRemotePath(currentPath.value, f.name), + ) + const base = + targets.length === 1 ? targets[0].name.replace(/\.[^.]+$/, '') : 'archive' + compressDest.value = joinRemotePath(currentPath.value, `${base}.tar.gz`) + compressFormat.value = 'tar.gz' + showCompress.value = true + } + + function openDecompressDialog(item: FileEntry) { + if (!selectedServer.value) return + if (!isArchiveFile(item.name)) { + snackbar('仅支持 .tar.gz / .tgz / .zip', 'warning') + return + } + decompressSource.value = joinRemotePath(currentPath.value, item.name) + decompressDest.value = currentPath.value + showDecompress.value = true + } + + async function doCompress() { + if (!selectedServer.value || !compressPaths.value.length || !compressDest.value) { + return + } + actionLoading.value = true + try { + let dest = compressDest.value + if (compressFormat.value === 'zip' && !dest.endsWith('.zip')) { + dest = dest.replace(/\.tar\.gz$/i, '.zip') + if (!dest.endsWith('.zip')) dest += '.zip' + } + await http.post('/sync/compress', { + server_id: selectedServer.value, + paths: compressPaths.value, + dest, + format: compressFormat.value, + }) + showCompress.value = false + clearSelection() + options.invalidateAfterMutation() + await options.browse({ force: true }) + snackbar('压缩完成') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '压缩失败' + snackbar(msg, 'error') + } finally { + actionLoading.value = false + } + } + + async function doDecompress() { + if (!selectedServer.value || !decompressSource.value) return + actionLoading.value = true + try { + await http.post('/sync/decompress', { + server_id: selectedServer.value, + path: decompressSource.value, + dest: decompressDest.value || '.', + }) + showDecompress.value = false + options.invalidateAfterMutation() + await options.browse({ force: true }) + snackbar('解压完成') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '解压失败' + snackbar(msg, 'error') + } finally { + actionLoading.value = false + } + } + + async function doNewFile() { + if (!selectedServer.value || !newFileName.value) return + const check = validatePathSegment(newFileName.value) + if (check !== true) { + snackbar(check, 'warning') + return + } + const name = newFileName.value + const path = joinRemotePath(currentPath.value, name) + actionLoading.value = true + try { + await http.post('/sync/write-file', { + server_id: selectedServer.value, + path, + content: '', + }) + showNewFile.value = false + newFileName.value = '' + invalidateCachedFile(selectedServer.value, path) + options.invalidateAfterMutation() + await options.browse({ force: true }) + snackbar('文件已创建') + await nextTick() + options.editorWorkbench.value?.openFile({ path, name, content: '' }) + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '创建失败' + snackbar(msg, 'error') + } finally { + actionLoading.value = false + } + } + + function startRename(item: FileEntry) { + renamingFile.value = item + newName.value = item.name + showRename.value = true + } + + async function doRename() { + if (!selectedServer.value || !renamingFile.value || !newName.value) return + try { + await http.post('/sync/file-ops', { + server_id: selectedServer.value, + operation: 'rename', + path: joinRemotePath(currentPath.value, renamingFile.value.name), + new_path: joinRemotePath(currentPath.value, newName.value), + }) + showRename.value = false + options.invalidateAfterMutation() + await options.browse({ force: true }) + snackbar('重命名成功') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '重命名失败' + snackbar(msg, 'error') + } + } + + function startChmod(item: FileEntry) { + chmodFile.value = item + showChmod.value = true + } + + async function doChmod(payload: { + mode: string + owner?: string + group?: string + recursive?: boolean + }) { + if (!selectedServer.value || !chmodFile.value) return + chmodLoading.value = true + try { + await http.post('/sync/chmod', { + server_id: selectedServer.value, + path: joinRemotePath(currentPath.value, chmodFile.value.name), + mode: payload.mode, + owner: payload.owner, + group: payload.group, + recursive: payload.recursive ?? false, + }) + showChmod.value = false + options.invalidateAfterMutation() + await options.browse({ force: true }) + snackbar('权限已修改') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '修改权限失败' + snackbar(msg, 'error') + } finally { + chmodLoading.value = false + } + } + + async function batchDelete() { + if (!selectedServer.value || !selectedFiles.value.length) return + if (!confirm(`确定删除 ${selectedFiles.value.length} 个文件?`)) return + let ok = 0 + let fail = 0 + for (const f of selectedFiles.value) { + try { + await http.post('/sync/file-ops', { + server_id: selectedServer.value, + operation: 'delete', + path: joinRemotePath(currentPath.value, f.name), + }) + ok++ + } catch { + fail++ + } + } + clearSelection() + options.invalidateAfterMutation() + await options.browse({ force: true }) + if (fail === 0) snackbar(`已删除 ${ok} 项`) + else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success') + } + + async function downloadFile(item: FileEntry) { + if (!selectedServer.value) return + try { + const remotePath = joinRemotePath(currentPath.value, item.name) + const { blob, filename } = await http.download('/sync/download', { + server_id: selectedServer.value, + path: remotePath, + }) + saveBlobAsFile(blob, filename) + snackbar(`已下载 ${filename}`) + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '下载失败' + snackbar(msg, 'error') + } + } + + async function confirmDeleteFile(item: FileEntry) { + deletingFile.value = item + showFileDelete.value = true + } + + async function doDeleteFile() { + if (!selectedServer.value || !deletingFile.value) return + actionLoading.value = true + try { + await http.post('/sync/file-ops', { + server_id: selectedServer.value, + operation: 'delete', + path: joinRemotePath(currentPath.value, deletingFile.value.name), + }) + showFileDelete.value = false + options.invalidateAfterMutation() + await options.browse({ force: true }) + snackbar('已删除') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '删除失败' + snackbar(msg, 'error') + } finally { + actionLoading.value = false + } + } + + async function doMkdir() { + if (!selectedServer.value || !mkdirName.value) return + const check = validatePathSegment(mkdirName.value) + if (check !== true) { + snackbar(check, 'warning') + return + } + actionLoading.value = true + try { + await http.post('/sync/file-ops', { + server_id: selectedServer.value, + operation: 'mkdir', + path: joinRemotePath(currentPath.value, mkdirName.value), + }) + showMkdir.value = false + mkdirName.value = '' + options.invalidateAfterMutation() + await options.browse({ force: true }) + snackbar('目录已创建') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '创建失败' + snackbar(msg, 'error') + } finally { + actionLoading.value = false + } + } + + async function doUpload() { + if (!selectedServer.value || !uploadFiles.value.length) return + uploading.value = true + try { + const form = new FormData() + for (const f of uploadFiles.value) form.append('files', f) + form.append('server_id', String(selectedServer.value)) + form.append('remote_path', currentPath.value) + await http.upload('/sync/upload', form) + showUpload.value = false + uploadFiles.value = [] + options.invalidateAfterMutation() + await options.browse({ force: true }) + snackbar('上传成功') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '上传失败' + snackbar(msg, 'error') + } finally { + uploading.value = false + } + } + + function isRowActionTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return Boolean(target.closest('button, a, input, .v-checkbox, .v-selection-control')) + } + + function fileRowProps(data: { item: FileEntry }) { + const item = data.item + return { + class: item.type === 'directory' ? 'files-row--dir' : undefined, + style: { + cursor: + item.type === 'directory' || item.type === 'symlink' ? 'pointer' : 'default', + }, + onClick: (e: MouseEvent) => { + if (isRowActionTarget(e.target)) return + if (e.detail !== 1) return + onRowClick(e, { item }) + }, + onDblclick: (e: MouseEvent) => { + if (isRowActionTarget(e.target)) return + onRowDblClick(e, { item }) + }, + onContextmenu: (e: MouseEvent) => { + if (isRowActionTarget(e.target)) return + onRowContext(e, { item }) + }, + } + } + + function onRowClick(_event: Event, { item }: { item: FileEntry }) { + if (item.type === 'directory' || item.type === 'symlink') { + options.openEntry(item) + } + } + + function onRowDblClick(_event: Event, { item }: { item: FileEntry }) { + if (item.type === 'file') { + void options.editFile(item) + } + } + + function onRowContext(e: MouseEvent, { item }: { item: FileEntry }) { + e.preventDefault() + contextFile.value = item + contextX.value = e.clientX + contextY.value = e.clientY + showContextMenu.value = true + } + + return { + dropActive, + showUpload, + showNewFile, + newFileName, + showMkdir, + showCompress, + compressDest, + compressFormat, + compressPaths, + showDecompress, + decompressSource, + decompressDest, + showFileDelete, + uploadFiles, + uploading, + mkdirName, + actionLoading, + deletingFile, + showRename, + newName, + renamingFile, + showChmod, + chmodLoading, + chmodFile, + showContextMenu, + contextX, + contextY, + contextFile, + copyToClipboard, + cutToClipboard, + pasteClipboard, + onDropFiles, + contextAction, + openCompressDialog, + openDecompressDialog, + doCompress, + doDecompress, + doNewFile, + startRename, + doRename, + startChmod, + doChmod, + batchDelete, + downloadFile, + confirmDeleteFile, + doDeleteFile, + doMkdir, + doUpload, + fileRowProps, + pasteDestLabel, + } +} diff --git a/frontend/src/composables/files/useFilesBrowse.ts b/frontend/src/composables/files/useFilesBrowse.ts new file mode 100644 index 00000000..514a8033 --- /dev/null +++ b/frontend/src/composables/files/useFilesBrowse.ts @@ -0,0 +1,177 @@ +import { computed, type Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useRoute, useRouter } from 'vue-router' +import { http } from '@/api' +import { useSnackbar } from '@/composables/useSnackbar' +import { useFilesStore } from '@/stores/files' +import type { BrowseResponse, FileEntry } from '@/types/api' +import { parseBrowseResponse } from '@/utils/fileBrowse' +import { + clearFilePreloadCache, + preloadDirectoryFiles, + pruneFilePreloadCache, +} from '@/utils/filePreload' +import { DEFAULT_FILES_ROOT, normalizeRemotePath, parentRemotePath } from '@/utils/remotePath' + +let _browseSeq = 0 +let _browseInFlight: Promise | null = null +let _browseInFlightKey = '' + +export function isNotFoundError(err: string): boolean { + return err.includes('No such file or directory') || err.includes('not a directory') +} + +export function friendlyBrowseError(err: string, path: string): string { + if (isNotFoundError(err)) { + return `路径不存在:${path},请检查服务器「target_path」设置` + } + if (err.includes('Permission denied') || err.includes('Operation not permitted')) { + return `无读取权限(${path})` + } + return err +} + +export function useFilesBrowse(fileFilter?: Ref) { + const snackbar = useSnackbar() + const route = useRoute() + const router = useRouter() + const store = useFilesStore() + const { + selectedServer, + currentPath, + files, + loading, + browseError, + pageReady, + filesCapability, + } = storeToRefs(store) + + function syncRouteQuery() { + const query: Record = { + ...(route.query as Record), + } + if (selectedServer.value) { + query.server_id = String(selectedServer.value) + query.path = + currentPath.value === DEFAULT_FILES_ROOT ? undefined : currentPath.value + } else { + delete query.server_id + delete query.path + } + router.replace({ query }) + } + + const emptyHint = computed(() => { + if (!pageReady.value) return '正在加载…' + if (!selectedServer.value) return '请先选择服务器' + if (browseError.value) return browseError.value + if (fileFilter?.value.trim()) return '无匹配文件' + const cap = filesCapability.value + if (cap?.is_root) { + return `目录为空(${currentPath.value}),可在上方修改路径或到「服务器」核对 target_path` + } + return `目录为空(${currentPath.value})` + }) + + async function browse(options?: { force?: boolean }) { + if (!selectedServer.value) return + const path = normalizeRemotePath(currentPath.value) + const serverId = selectedServer.value + const browseKey = `${serverId}:${path}` + + if (!options?.force) { + const cached = store.getCached(serverId, path) + if (cached) { + files.value = cached + browseError.value = '' + loading.value = false + syncRouteQuery() + return + } + } + + if (_browseInFlight && _browseInFlightKey === browseKey) { + return _browseInFlight + } + _browseInFlightKey = browseKey + + const seq = ++_browseSeq + loading.value = true + currentPath.value = path + + _browseInFlight = (async () => { + try { + const res = await http.post('/sync/browse', { + server_id: serverId, + path, + }) + if (seq !== _browseSeq) return + const { items, error } = parseBrowseResponse(res) + + if (error && isNotFoundError(error) && path !== '/') { + browseError.value = '' + loading.value = false + const parent = parentRemotePath(path) + if (parent) { + snackbar(`路径 "${path}" 不存在,已自动跳转到上级目录`, 'info') + currentPath.value = parent + void browse() + return + } + } + + browseError.value = error ? friendlyBrowseError(error, path) : '' + if (error && !isNotFoundError(error)) { + snackbar(friendlyBrowseError(error, path), 'warning') + } + files.value = items + if (!error) { + store.setCached(serverId, path, items) + } + if (serverId && items.length) { + pruneFilePreloadCache(serverId, path) + preloadDirectoryFiles(serverId, path, items) + } + } catch { + if (seq !== _browseSeq) return + files.value = [] + snackbar('浏览目录失败', 'error') + } finally { + if (seq === _browseSeq) { + loading.value = false + syncRouteQuery() + } + if (_browseInFlightKey === browseKey) { + _browseInFlight = null + _browseInFlightKey = '' + } + } + })() + + return _browseInFlight + } + + function browseCurrent() { + if (!selectedServer.value) { + snackbar('请先选择服务器', 'warning') + return + } + currentPath.value = normalizeRemotePath(currentPath.value) + store.invalidateCache(selectedServer.value, currentPath.value) + void browse({ force: true }) + } + + function invalidateAfterMutation(parentPath?: string) { + if (!selectedServer.value) return + const p = parentPath ?? currentPath.value + store.invalidateCache(selectedServer.value, normalizeRemotePath(p)) + clearFilePreloadCache() + } + + return { + emptyHint, + browse, + browseCurrent, + invalidateAfterMutation, + } +} diff --git a/frontend/src/composables/files/useFilesEditor.ts b/frontend/src/composables/files/useFilesEditor.ts new file mode 100644 index 00000000..e9915286 --- /dev/null +++ b/frontend/src/composables/files/useFilesEditor.ts @@ -0,0 +1,131 @@ +import { nextTick, ref, type Ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useSnackbar } from '@/composables/useSnackbar' +import { useFilesStore } from '@/stores/files' +import type { FileEntry } from '@/types/api' +import { isPermissionDeniedMessage } from '@/utils/fileMode' +import { + exceedsEditorMaxSize, + FILE_EDITOR_MAX_BYTES, + FILE_PRELOAD_MAX_BYTES, + invalidateCachedFile, + readRemoteFileContent, +} from '@/utils/filePreload' +import { joinRemotePath } from '@/utils/remotePath' + +function formatSize(bytes: number | undefined) { + if (!bytes) return '—' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +export function useFilesEditor(options: { + browse: (opts?: { force?: boolean }) => Promise + invalidateAfterMutation: (parentPath?: string) => void + actionLoading: Ref +}) { + const snackbar = useSnackbar() + const store = useFilesStore() + const { selectedServer, currentPath, lastReadDeniedPath, accessHintDismissed } = + storeToRefs(store) + + const showPreview = ref(false) + const previewTitle = ref('') + const previewContent = ref('') + const previewLoading = ref(false) + + const editorWorkbench = ref<{ + openFile: (p: { path: string; name: string; content: string }) => void + } | null>(null) + + function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean { + if (exceedsEditorMaxSize(item.size)) { + snackbar( + `文件超过 2MB(${formatSize(item.size)}),无法${action},请下载或使用终端`, + 'warning', + ) + return true + } + return false + } + + function onEditorSaved(path: string) { + if (selectedServer.value && path) { + invalidateCachedFile(selectedServer.value, path) + } + options.invalidateAfterMutation() + void options.browse({ force: true }) + } + + async function previewFile(item: FileEntry) { + if (!selectedServer.value) return + if (rejectOversizedFile(item, '查看')) return + const remotePath = joinRemotePath(currentPath.value, item.name) + previewTitle.value = item.name + previewContent.value = '' + previewLoading.value = true + showPreview.value = true + if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) { + snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info') + } + try { + const raw = await readRemoteFileContent( + selectedServer.value, + remotePath, + FILE_EDITOR_MAX_BYTES, + ) + previewContent.value = raw || '(空文件)' + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '读取失败' + previewContent.value = msg + if (isPermissionDeniedMessage(msg)) { + lastReadDeniedPath.value = item.name + accessHintDismissed.value = false + } + snackbar(msg, 'error') + } finally { + previewLoading.value = false + } + } + + async function editFile(item: FileEntry) { + if (!selectedServer.value) return + if (rejectOversizedFile(item, '编辑')) return + if ((item.size ?? 0) > FILE_PRELOAD_MAX_BYTES) { + snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info') + } + options.actionLoading.value = true + try { + const path = joinRemotePath(currentPath.value, item.name) + const content = await readRemoteFileContent( + selectedServer.value, + path, + FILE_EDITOR_MAX_BYTES, + ) + await nextTick() + editorWorkbench.value?.openFile({ path, name: item.name, content }) + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '读取失败' + if (isPermissionDeniedMessage(msg)) { + lastReadDeniedPath.value = item.name + accessHintDismissed.value = false + } + snackbar(msg, 'error') + } finally { + options.actionLoading.value = false + } + } + + return { + showPreview, + previewTitle, + previewContent, + previewLoading, + editorWorkbench, + previewFile, + editFile, + onEditorSaved, + formatSize, + } +} diff --git a/frontend/src/composables/files/useFilesNavigation.ts b/frontend/src/composables/files/useFilesNavigation.ts new file mode 100644 index 00000000..8de6e23c --- /dev/null +++ b/frontend/src/composables/files/useFilesNavigation.ts @@ -0,0 +1,206 @@ +import { computed, onMounted, watch } from 'vue' +import { storeToRefs } from 'pinia' +import { useRoute, useRouter } from 'vue-router' +import { useServerList } from '@/composables/useServerList' +import { useSnackbar } from '@/composables/useSnackbar' +import { useFilesStore } from '@/stores/files' +import type { FileEntry } from '@/types/api' +import { + buildRemoteBreadcrumbs, + DEFAULT_FILES_ROOT, + joinRemotePath, + normalizeRemotePath, + parentRemotePath, + type RemoteBreadcrumbItem, +} from '@/utils/remotePath' +import { FILES_HINT_DISMISSED_KEY, FILES_LAST_SERVER_KEY } from './constants' + +export function useFilesNavigation(options: { + browse: (opts?: { force?: boolean }) => Promise + refreshFilesCapability: () => Promise + clearSelection: () => void +}) { + const snackbar = useSnackbar() + const route = useRoute() + const router = useRouter() + const { servers: serverList, loadServers } = useServerList() + const store = useFilesStore() + const { + selectedServer, + currentPath, + pageReady, + routeReady, + initing, + accessHintDismissed, + } = storeToRefs(store) + + const breadcrumbs = computed((): RemoteBreadcrumbItem[] => + buildRemoteBreadcrumbs(currentPath.value), + ) + + const canGoUp = computed(() => parentRemotePath(currentPath.value) !== null) + + function defaultPathForServer(serverId: number | null): string { + if (serverId == null) return DEFAULT_FILES_ROOT + const srv = serverList.value.find((s) => s.id === serverId) + const tp = srv?.target_path?.trim() + return tp ? normalizeRemotePath(tp) : DEFAULT_FILES_ROOT + } + + function pathFromRouteQuery(): string { + const qPath = route.query.path + if (typeof qPath === 'string' && qPath.trim()) { + return normalizeRemotePath(qPath) + } + return defaultPathForServer(selectedServer.value) + } + + function resolveInitialServerId(): number | null { + const qServer = route.query.server_id + if (qServer) { + const id = Number(qServer) + if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id + } + const stored = sessionStorage.getItem(FILES_LAST_SERVER_KEY) + if (stored) { + const id = Number(stored) + if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id + } + return serverList.value[0]?.id ?? null + } + + function navigateToPath(path: string) { + if (!selectedServer.value) { + snackbar('请先选择服务器', 'warning') + return + } + currentPath.value = normalizeRemotePath(path) + options.clearSelection() + void options.browse() + } + + function openEntry(item: FileEntry) { + if (item.type === 'directory') { + navigateToPath(joinRemotePath(currentPath.value, item.name)) + return + } + if (item.type === 'symlink') { + const target = item.link_target?.trim() + if (target?.startsWith('/')) { + navigateToPath(target) + } else { + snackbar( + target ? `符号链接 → ${target}` : '相对符号链接(请在终端中打开)', + 'info', + ) + } + } + } + + function goUp() { + const parent = parentRemotePath(currentPath.value) + if (parent != null) navigateToPath(parent) + } + + function onBreadcrumbClick(index: number) { + const bc = breadcrumbs.value[index] + if (bc && !bc.disabled) navigateToPath(bc.path) + } + + function openInTerminal() { + if (!selectedServer.value) return + router.push({ + path: '/terminal', + query: { + server_id: String(selectedServer.value), + path: currentPath.value || '/', + }, + }) + } + + function onServerChange() { + if (initing.value) return + if (selectedServer.value) { + sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value)) + } + store.invalidateCache() + currentPath.value = defaultPathForServer(selectedServer.value) + options.clearSelection() + store.resetForServerChange() + sessionStorage.removeItem(FILES_HINT_DISMISSED_KEY) + void options.refreshFilesCapability() + void options.browse({ force: true }) + } + + onMounted(async () => { + initing.value = true + try { + await loadServers() + if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) { + accessHintDismissed.value = true + } + if (!serverList.value.length) { + snackbar('暂无可用服务器,请先在「服务器」页添加', 'warning') + pageReady.value = true + return + } + const initialId = resolveInitialServerId() + if (initialId == null) { + pageReady.value = true + return + } + selectedServer.value = initialId + const qPath = route.query.path + currentPath.value = + typeof qPath === 'string' && qPath + ? normalizeRemotePath(qPath) + : defaultPathForServer(initialId) + await options.refreshFilesCapability() + await options.browse() + pageReady.value = true + routeReady.value = true + } finally { + initing.value = false + } + }) + + watch( + () => route.query.server_id, + (id) => { + if (!routeReady.value) return + if (id && Number(id) !== selectedServer.value) { + selectedServer.value = Number(id) + currentPath.value = pathFromRouteQuery() + store.resetForServerChange() + void options.refreshFilesCapability() + void options.browse({ force: true }) + } + }, + ) + + watch( + () => route.query.path, + () => { + if (!routeReady.value || !selectedServer.value) return + const next = pathFromRouteQuery() + if (next !== normalizeRemotePath(currentPath.value)) { + currentPath.value = next + options.clearSelection() + void options.browse() + } + }, + ) + + return { + serverList, + breadcrumbs, + canGoUp, + navigateToPath, + openEntry, + goUp, + onBreadcrumbClick, + openInTerminal, + onServerChange, + defaultPathForServer, + } +} diff --git a/frontend/src/composables/files/useFilesPage.ts b/frontend/src/composables/files/useFilesPage.ts new file mode 100644 index 00000000..ba7a6685 --- /dev/null +++ b/frontend/src/composables/files/useFilesPage.ts @@ -0,0 +1,270 @@ +/** + * 文件管理页 — 组合各域 composable,供 FilesPage.vue 使用 + */ +import { computed, ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useFilesClipboard } from '@/composables/useFilesClipboard' +import { useFilesHotkeys } from '@/composables/useFilesHotkeys' +import { useFilesStore } from '@/stores/files' +import type { FileEntry } from '@/types/api' +import { + FILE_EXT_FILTERS, + isArchiveFile, + matchesExtensionFilter, + sortFileEntries, + type FileSortKey, +} from '@/utils/fileSort' +import { + formatOwnerChip, + isEntryLikelyUnreadable, + ownerChipColor, + permissionTooltip, +} from '@/utils/fileMode' +import { isPreloadEligible } from '@/utils/filePreload' +import { validatePathSegment } from '@/utils/remotePath' +import { required } from '@/utils/validation' +import { useFilesActions } from './useFilesActions' +import { useFilesBrowse } from './useFilesBrowse' +import { useFilesEditor } from './useFilesEditor' +import { useFilesNavigation } from './useFilesNavigation' +import { useFilesPermissions } from './useFilesPermissions' +import { useFilesSelection } from './useFilesSelection' + +export function useFilesPage() { + const fileFilter = ref('') + const extFilter = ref('') + const sortBy = ref('name') + const sortOptions = [ + { title: '名称', value: 'name' as const }, + { title: '大小', value: 'size' as const }, + { title: '修改时间', value: 'modified' as const }, + ] + + const store = useFilesStore() + const { + selectedServer, + currentPath, + files, + loading, + browseError, + pageReady, + setupSudoLoading, + setupSudoDone, + } = storeToRefs(store) + + const selection = useFilesSelection() + const { selectedFiles, selectedFileCount, clearSelection } = selection + + const { emptyHint, browse, browseCurrent, invalidateAfterMutation } = + useFilesBrowse(fileFilter) + + const permissions = useFilesPermissions({ browse }) + const { + sshUserForFiles, + filesAccessHint, + dismissFilesAccessHint, + setupFilesSudo, + } = permissions + + const nav = useFilesNavigation({ + browse, + refreshFilesCapability: permissions.refreshFilesCapability, + clearSelection, + }) + + const actionLoading = ref(false) + + const editor = useFilesEditor({ + browse, + invalidateAfterMutation, + actionLoading, + }) + + const actions = useFilesActions({ + browse, + invalidateAfterMutation, + selection, + openEntry: nav.openEntry, + editFile: editor.editFile, + previewFile: editor.previewFile, + openInTerminal: nav.openInTerminal, + editorWorkbench: editor.editorWorkbench, + actionLoading, + }) + + const { hasClipboard, clipboardSummary, canPasteOnServer } = useFilesClipboard() + const canPasteHere = computed(() => canPasteOnServer(selectedServer.value)) + + const displayedFiles = computed(() => { + let list = sortFileEntries(files.value, sortBy.value, 'asc') + const ext = extFilter.value + if (ext) list = list.filter((f) => matchesExtensionFilter(f.name, ext)) + const q = fileFilter.value.trim().toLowerCase() + if (q) list = list.filter((f) => f.name.toLowerCase().includes(q)) + return list + }) + + const statusSummary = computed(() => { + const dirs = files.value.filter((f) => f.type === 'directory').length + const regular = files.value.filter((f) => f.type === 'file').length + const links = files.value.filter((f) => f.type === 'symlink').length + const preloadable = files.value.filter(isPreloadEligible).length + const parts = [`${dirs} 个目录`, `${regular} 个文件`] + if (links) parts.push(`${links} 个链接`) + if (preloadable) parts.push(`${preloadable} 个 ≤300KB 可预加载`) + return `${parts.join(',')} · ${currentPath.value}` + }) + + function isArchiveName(name: string): boolean { + return isArchiveFile(name) + } + + function entryIcon(item: FileEntry): string { + if (item.type === 'directory') return 'mdi-folder' + if (item.type === 'symlink') return 'mdi-link-variant' + return 'mdi-file-outline' + } + + function entryIconColor(item: FileEntry): string { + if (item.type === 'directory') return 'amber' + if (item.type === 'symlink') return 'teal' + return 'blue' + } + + function isRegularFile(item: FileEntry): boolean { + return item.type === 'file' + } + + const fileHeaders = [ + { title: '名称', key: 'name' }, + { title: '归属', key: 'owner', width: 110, sortable: false }, + { title: '大小', key: 'size', width: 120 }, + { title: '修改时间', key: 'modified', width: 180 }, + { title: '操作', key: 'actions', width: 200, align: 'end' as const }, + ] + + useFilesHotkeys( + { + refresh: browseCurrent, + goUp: nav.goUp, + renameSelection: () => { + if (selectedFiles.value.length === 1) { + actions.startRename(selectedFiles.value[0]) + } + }, + deleteSelection: () => { + if (selectedFiles.value.length > 0) void actions.batchDelete() + }, + selectAll: () => { + selectedFiles.value = [...displayedFiles.value] + }, + copySelection: () => actions.copyToClipboard(), + cutSelection: () => actions.cutToClipboard(), + pasteClipboard: () => { + void actions.pasteClipboard() + }, + }, + selectedFiles, + canPasteHere, + ) + + return { + FILE_EXT_FILTERS, + validatePathSegment, + required, + fileFilter, + extFilter, + sortBy, + sortOptions, + selectedServer, + currentPath, + loading, + browseError, + pageReady, + setupSudoLoading, + setupSudoDone, + serverList: nav.serverList, + onServerChange: nav.onServerChange, + browseCurrent, + filesAccessHint, + dismissFilesAccessHint, + setupFilesSudo, + sshUserForFiles, + statusSummary, + hasClipboard, + clipboardSummary, + pasteDestLabel: actions.pasteDestLabel, + canPasteHere, + breadcrumbs: nav.breadcrumbs, + onBreadcrumbClick: nav.onBreadcrumbClick, + canGoUp: nav.canGoUp, + goUp: nav.goUp, + selectedFiles, + selectedFileCount, + displayedFiles, + fileHeaders, + fileRowProps: actions.fileRowProps, + dropActive: actions.dropActive, + onDropFiles: actions.onDropFiles, + emptyHint, + entryIcon, + entryIconColor, + isEntryLikelyUnreadable, + formatOwnerChip, + ownerChipColor, + permissionTooltip, + formatSize: editor.formatSize, + isRegularFile, + isArchiveName, + showUpload: actions.showUpload, + uploadFiles: actions.uploadFiles, + uploading: actions.uploading, + doUpload: actions.doUpload, + showNewFile: actions.showNewFile, + newFileName: actions.newFileName, + doNewFile: actions.doNewFile, + showMkdir: actions.showMkdir, + mkdirName: actions.mkdirName, + doMkdir: actions.doMkdir, + showFileDelete: actions.showFileDelete, + deletingFile: actions.deletingFile, + doDeleteFile: actions.doDeleteFile, + showRename: actions.showRename, + newName: actions.newName, + doRename: actions.doRename, + showChmod: actions.showChmod, + chmodFile: actions.chmodFile, + chmodLoading: actions.chmodLoading, + doChmod: actions.doChmod, + showCompress: actions.showCompress, + compressDest: actions.compressDest, + compressFormat: actions.compressFormat, + compressPaths: actions.compressPaths, + doCompress: actions.doCompress, + showDecompress: actions.showDecompress, + decompressSource: actions.decompressSource, + decompressDest: actions.decompressDest, + doDecompress: actions.doDecompress, + showContextMenu: actions.showContextMenu, + contextX: actions.contextX, + contextY: actions.contextY, + contextFile: actions.contextFile, + contextAction: actions.contextAction, + actionLoading, + batchDelete: actions.batchDelete, + openCompressDialog: actions.openCompressDialog, + showPreview: editor.showPreview, + previewTitle: editor.previewTitle, + previewContent: editor.previewContent, + previewLoading: editor.previewLoading, + editorWorkbench: editor.editorWorkbench, + onEditorSaved: editor.onEditorSaved, + navigateToPath: nav.navigateToPath, + pasteClipboard: actions.pasteClipboard, + editFile: editor.editFile, + previewFile: editor.previewFile, + downloadFile: actions.downloadFile, + confirmDeleteFile: actions.confirmDeleteFile, + openInTerminal: nav.openInTerminal, + } +} diff --git a/frontend/src/composables/files/useFilesPermissions.ts b/frontend/src/composables/files/useFilesPermissions.ts new file mode 100644 index 00000000..b8e32833 --- /dev/null +++ b/frontend/src/composables/files/useFilesPermissions.ts @@ -0,0 +1,101 @@ +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { http } from '@/api' +import { useSnackbar } from '@/composables/useSnackbar' +import { useFilesStore } from '@/stores/files' +import type { FilesCapabilityResult } from '@/types/api' +import { + buildFilesAccessHint, + isEntryLikelyUnreadable, +} from '@/utils/fileMode' +import { FILES_HINT_DISMISSED_KEY } from './constants' +import { isNotFoundError } from './useFilesBrowse' + +export function useFilesPermissions(options: { + browse: (opts?: { force?: boolean }) => Promise +}) { + const snackbar = useSnackbar() + const store = useFilesStore() + const { + selectedServer, + files, + browseError, + filesCapability, + lastReadDeniedPath, + accessHintDismissed, + setupSudoLoading, + setupSudoDone, + } = storeToRefs(store) + + const sshUserForFiles = computed(() => filesCapability.value?.ssh_user ?? null) + + const unreadableFileCount = computed(() => { + const user = sshUserForFiles.value + if (!user) return 0 + return files.value.filter((f) => isEntryLikelyUnreadable(f, user)).length + }) + + const filesAccessHint = computed(() => { + if (accessHintDismissed.value || !selectedServer.value) return null + if (browseError.value && isNotFoundError(browseError.value)) return null + const cap = filesCapability.value + return buildFilesAccessHint({ + sshUser: cap?.ssh_user, + canSudo: cap?.can_sudo_nopasswd ?? false, + whitelistOk: cap?.whitelist_ok ?? false, + isRootSsh: cap?.is_root ?? false, + filesElevation: cap?.files_elevation, + capabilityMessage: cap?.message, + lastReadDeniedPath: lastReadDeniedPath.value, + unreadableCount: unreadableFileCount.value, + }) + }) + + function dismissFilesAccessHint() { + accessHintDismissed.value = true + sessionStorage.setItem(FILES_HINT_DISMISSED_KEY, '1') + } + + async function refreshFilesCapability() { + if (!selectedServer.value) { + filesCapability.value = null + return + } + try { + filesCapability.value = await http.get( + `/servers/${selectedServer.value}/files-capability`, + ) + } catch { + filesCapability.value = null + } + } + + async function setupFilesSudo() { + if (!selectedServer.value) return + setupSudoLoading.value = true + try { + const res = await http.post<{ ok: boolean; message: string }>( + `/servers/${selectedServer.value}/setup-files-sudo`, + {}, + ) + setupSudoDone.value = true + snackbar(res.message || 'sudoers 配置成功,文件管理器现在可自动提权', 'success') + dismissFilesAccessHint() + await refreshFilesCapability() + await options.browse({ force: true }) + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '配置失败' + snackbar(`一键配置失败:${msg}`, 'error') + } finally { + setupSudoLoading.value = false + } + } + + return { + sshUserForFiles, + filesAccessHint, + dismissFilesAccessHint, + refreshFilesCapability, + setupFilesSudo, + } +} diff --git a/frontend/src/composables/files/useFilesSelection.ts b/frontend/src/composables/files/useFilesSelection.ts new file mode 100644 index 00000000..6c3ac6cd --- /dev/null +++ b/frontend/src/composables/files/useFilesSelection.ts @@ -0,0 +1,28 @@ +import { computed, ref } from 'vue' +import { storeToRefs } from 'pinia' +import { useFilesStore } from '@/stores/files' +import type { FileEntry } from '@/types/api' +import { joinRemotePath } from '@/utils/remotePath' + +export function useFilesSelection() { + const store = useFilesStore() + const { currentPath } = storeToRefs(store) + const selectedFiles = ref([]) + const selectedFileCount = computed(() => selectedFiles.value.length) + + function clearSelection() { + selectedFiles.value = [] + } + + function selectedAbsolutePaths(extra?: FileEntry[]): string[] { + const items = extra?.length ? extra : selectedFiles.value + return items.map((f) => joinRemotePath(currentPath.value, f.name)) + } + + return { + selectedFiles, + selectedFileCount, + clearSelection, + selectedAbsolutePaths, + } +} diff --git a/frontend/src/pages/FilesPage.vue b/frontend/src/pages/FilesPage.vue index 49c3e6b8..d8ffb2b1 100644 --- a/frontend/src/pages/FilesPage.vue +++ b/frontend/src/pages/FilesPage.vue @@ -515,1072 +515,116 @@ +