refactor(files): P1 split composables, Pinia store, directory cache

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-02 05:05:26 +08:00
parent cb738a8e9a
commit 7cbddf5d48
13 changed files with 1817 additions and 1058 deletions
@@ -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 请求
@@ -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` 单文件可独立回退。
@@ -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
+7
View File
@@ -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'
@@ -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<typeof useFilesSelection>
export function useFilesActions(options: {
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
invalidateAfterMutation: (parentPath?: string) => void
selection: SelectionApi
openEntry: (item: FileEntry) => void
editFile: (item: FileEntry) => Promise<void>
previewFile: (item: FileEntry) => Promise<void>
openInTerminal: () => void
editorWorkbench: Ref<{ openFile: (p: { path: string; name: string; content: string }) => void } | null>
actionLoading?: Ref<boolean>
}) {
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<string[]>([])
const showDecompress = ref(false)
const decompressSource = ref('')
const decompressDest = ref('.')
const showFileDelete = ref(false)
const uploadFiles = ref<File[]>([])
const uploading = ref(false)
const mkdirName = ref('')
const actionLoading = options.actionLoading ?? ref(false)
const deletingFile = ref<FileEntry | null>(null)
const showRename = ref(false)
const newName = ref('')
const renamingFile = ref<FileEntry | null>(null)
const showChmod = ref(false)
const chmodLoading = ref(false)
const chmodFile = ref<FileEntry | null>(null)
const showContextMenu = ref(false)
const contextX = ref(0)
const contextY = ref(0)
const contextFile = ref<FileEntry | null>(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,
}
}
@@ -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<void> | 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<string>) {
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<string, string | undefined> = {
...(route.query as Record<string, string>),
}
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<BrowseResponse | FileEntry[]>('/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,
}
}
@@ -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<void | undefined>
invalidateAfterMutation: (parentPath?: string) => void
actionLoading: Ref<boolean>
}) {
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,
}
}
@@ -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<void | undefined>
refreshFilesCapability: () => Promise<void>
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,
}
}
@@ -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<FileSortKey>('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,
}
}
@@ -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<void | undefined>
}) {
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<FilesCapabilityResult>(
`/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,
}
}
@@ -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<FileEntry[]>([])
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,
}
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
/**
* Pinia — 文件管理器共享状态(路径/列表/权限/目录缓存)
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { FileEntry, FilesCapabilityResult } from '@/types/api'
import { DEFAULT_FILES_ROOT } from '@/utils/remotePath'
import { FILES_CACHE_MAX_ENTRIES, FILES_CACHE_TTL_MS } from '@/composables/files/constants'
interface FilesCacheEntry {
items: FileEntry[]
at: number
}
function cacheKey(serverId: number, path: string): string {
return `${serverId}:${path}`
}
export const useFilesStore = defineStore('files', () => {
const selectedServer = ref<number | null>(null)
const currentPath = ref(DEFAULT_FILES_ROOT)
const files = ref<FileEntry[]>([])
const loading = ref(false)
const browseError = ref('')
const filesCapability = ref<FilesCapabilityResult | null>(null)
const lastReadDeniedPath = ref<string | null>(null)
const accessHintDismissed = ref(false)
const setupSudoLoading = ref(false)
const setupSudoDone = ref(false)
const pageReady = ref(false)
/** 路由 watch 与 onMounted 协调 */
const routeReady = ref(false)
/** 阻止 v-select 初始化触发 onServerChange */
const initing = ref(false)
const fileCache = new Map<string, FilesCacheEntry>()
function getCached(serverId: number, path: string): FileEntry[] | null {
const entry = fileCache.get(cacheKey(serverId, path))
if (!entry) return null
if (Date.now() - entry.at > FILES_CACHE_TTL_MS) {
fileCache.delete(cacheKey(serverId, path))
return null
}
return entry.items
}
function setCached(serverId: number, path: string, items: FileEntry[]) {
const key = cacheKey(serverId, path)
fileCache.set(key, { items, at: Date.now() })
if (fileCache.size > FILES_CACHE_MAX_ENTRIES) {
const oldest = [...fileCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
if (oldest) fileCache.delete(oldest[0])
}
}
function invalidateCache(serverId?: number | null, path?: string) {
if (serverId == null) {
fileCache.clear()
return
}
if (path == null) {
const prefix = `${serverId}:`
for (const key of [...fileCache.keys()]) {
if (key.startsWith(prefix)) fileCache.delete(key)
}
return
}
fileCache.delete(cacheKey(serverId, path))
}
function resetForServerChange() {
lastReadDeniedPath.value = null
accessHintDismissed.value = false
setupSudoDone.value = false
}
return {
selectedServer,
currentPath,
files,
loading,
browseError,
filesCapability,
lastReadDeniedPath,
accessHintDismissed,
setupSudoLoading,
setupSudoDone,
pageReady,
routeReady,
initing,
getCached,
setCached,
invalidateCache,
resetForServerChange,
}
})