feat(files): native instance-scoped file browser (replaces broken VueFinder)
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 42s
CI / integration (push) Successful in 22s

FileManagerView rewritten as a native DS browser on the per-instance
file bridge: instance selector, breadcrumb nav, dir-first listing
(name/size/modified), folder drill-down, inline file editor (read/save),
toolbar (new folder/file/refresh), per-row rename + delete-confirm.
New files store wraps the /instances/:id/files* endpoints. VueFinder
import + RemoteDriver fully removed — no more retired-protocol /api/files.
Honest empty (no instance -> Server page) + error (retry) states, never
the global error boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 19:31:01 -04:00
parent 589516a021
commit 355a53f6e3
2 changed files with 637 additions and 35 deletions

View File

@@ -0,0 +1,136 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '@/composables/useApi'
import { useInstancesStore } from '@/stores/instances'
export interface FileEntry {
name: string
path: string
is_dir: boolean
size: number
modified: string
}
/**
* Per-instance file browser store.
* All operations target `/api/instances/{id}/...` — jailed to instance root.
* Guard: if no current instance, list() sets error and bails out early.
*/
export const useFilesStore = defineStore('files', () => {
const api = useApi()
const cwd = ref<string>('')
const entries = ref<FileEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
/** Join two relative path segments with a single forward slash. */
function joinPath(base: string, name: string): string {
if (!base) return name
return `${base}/${name}`
}
function currentId(): string | null {
// Retrieve fresh from the store each call — avoids stale closure.
return useInstancesStore().currentId
}
/** List a directory. Sets cwd + entries. Does NOT throw — sets error. */
async function list(path: string): Promise<void> {
const id = currentId()
if (!id) {
error.value = 'No instance — connect the host agent'
entries.value = []
return
}
loading.value = true
error.value = null
try {
const data = await api.get<{ entries: FileEntry[] }>(
`/instances/${id}/files?path=${encodeURIComponent(path)}`,
)
cwd.value = path
entries.value = data.entries ?? []
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load directory'
} finally {
loading.value = false
}
}
/** Read a text file. Returns content string. Throws on error (binary/too big/not found). */
async function readFile(path: string): Promise<string> {
const id = currentId()
if (!id) throw new Error('No instance selected')
const data = await api.get<{ content: string }>(
`/instances/${id}/file?path=${encodeURIComponent(path)}`,
)
return data.content ?? ''
}
/** Write / overwrite a text file. Throws on error. */
async function writeFile(path: string, content: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.put(`/instances/${id}/file`, { path, content })
}
/** Delete a file or directory (recursive). Throws on error. */
async function del(path: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/delete`, { path })
}
/** Rename within the same parent. `name` is the bare new filename. Throws on error. */
async function rename(path: string, name: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/rename`, { path, name })
}
/** Create a directory (and all missing ancestors). Throws on error. */
async function mkdir(path: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/mkdir`, { path })
}
/** Create an empty file. Throws on error. */
async function mkfile(path: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/mkfile`, { path })
}
/** Move a file or directory. Both paths are relative to the instance root. Throws on error. */
async function move(path: string, dest: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/move`, { path, dest })
}
/** Copy a file or directory. Both paths are relative to the instance root. Throws on error. */
async function copy(path: string, dest: string): Promise<void> {
const id = currentId()
if (!id) throw new Error('No instance selected')
await api.post(`/instances/${id}/files/copy`, { path, dest })
}
return {
cwd,
entries,
loading,
error,
joinPath,
list,
readFile,
writeFile,
del,
rename,
mkdir,
mkfile,
move,
copy,
}
})

View File

@@ -1,28 +1,176 @@
<script setup lang="ts">
import { computed } from 'vue'
import { VueFinder, RemoteDriver } from 'vuefinder'
import { useAuthStore } from '@/stores/auth'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useInstancesStore } from '@/stores/instances'
import { useFilesStore } from '@/stores/files'
import { useToastStore } from '@/stores/toast'
import { safeDate, safeFileSize } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Icon from '@/components/ds/core/Icon.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
const auth = useAuthStore()
const router = useRouter()
const instancesStore = useInstancesStore()
const files = useFilesStore()
const toast = useToastStore()
// Recreate the RemoteDriver reactively so the token stays current across
// automatic refresh cycles (useApi composable silently rotates accessToken).
const driver = computed(
() =>
new RemoteDriver({
baseURL: '/api/files',
token: auth.accessToken ?? undefined,
})
)
// ---- Editor state ----
const editorPath = ref<string | null>(null)
const editorContent = ref('')
const editorLoading = ref(false)
const editorSaving = ref(false)
// Non-persistent config passed to VueFinder per session.
// maxFileSize in bytes — 10 MB limit matches the backend upload ceiling.
const finderConfig = {
theme: 'midnight',
maxFileSize: 10 * 1024 * 1024,
showMenuBar: true,
showToolbar: true,
// ---- Inline confirm-delete ----
const pendingDelete = ref<string | null>(null)
// ---- Sorted entries: dirs first, then alpha ----
const sortedEntries = computed(() => {
return [...files.entries].sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1
return a.name.localeCompare(b.name)
})
})
// ---- Breadcrumbs from cwd ----
const breadcrumbs = computed<{ label: string; path: string }[]>(() => {
const crumbs: { label: string; path: string }[] = [{ label: 'Root', path: '' }]
if (!files.cwd) return crumbs
const parts = files.cwd.split('/').filter(Boolean)
let acc = ''
for (const p of parts) {
acc = acc ? `${acc}/${p}` : p
crumbs.push({ label: p, path: acc })
}
return crumbs
})
// ---- Parent path for "Up" button ----
const parentPath = computed<string | null>(() => {
if (!files.cwd) return null
const idx = files.cwd.lastIndexOf('/')
return idx < 0 ? '' : files.cwd.slice(0, idx)
})
// ---- Lifecycle ----
onMounted(async () => {
await instancesStore.fetchInstances()
await files.list('')
})
// ---- Instance switch ----
async function onInstanceChange(e: Event) {
const id = (e.target as HTMLSelectElement).value
instancesStore.select(id)
editorPath.value = null
await files.list('')
}
// ---- Navigation ----
async function navigate(path: string) {
editorPath.value = null
pendingDelete.value = null
await files.list(path)
}
// ---- Open a file in the editor ----
async function openFile(path: string) {
editorLoading.value = true
try {
const content = await files.readFile(path)
editorPath.value = path
editorContent.value = content
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Cannot open file (binary or too large)')
} finally {
editorLoading.value = false
}
}
function closeEditor() {
editorPath.value = null
editorContent.value = ''
}
async function saveFile() {
if (!editorPath.value) return
editorSaving.value = true
try {
await files.writeFile(editorPath.value, editorContent.value)
toast.success('File saved')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to save file')
} finally {
editorSaving.value = false
}
}
// ---- Toolbar: New folder ----
async function newFolder() {
const name = window.prompt('Folder name:')
if (!name || !name.trim()) return
if (name.includes('/') || name.includes('\\')) {
toast.error('Folder name cannot contain path separators')
return
}
try {
await files.mkdir(files.joinPath(files.cwd, name.trim()))
toast.success('Folder created')
await files.list(files.cwd)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to create folder')
}
}
// ---- Toolbar: New file ----
async function newFile() {
const name = window.prompt('File name:')
if (!name || !name.trim()) return
if (name.includes('/') || name.includes('\\')) {
toast.error('File name cannot contain path separators')
return
}
try {
await files.mkfile(files.joinPath(files.cwd, name.trim()))
toast.success('File created')
await files.list(files.cwd)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to create file')
}
}
// ---- Row: Rename ----
async function renameEntry(path: string, isDir: boolean) {
const current = path.split('/').pop() ?? path
const name = window.prompt('New name:', current)
if (!name || !name.trim() || name.trim() === current) return
if (name.includes('/') || name.includes('\\')) {
toast.error('Name cannot contain path separators')
return
}
try {
await files.rename(path, name.trim())
toast.success(`${isDir ? 'Folder' : 'File'} renamed`)
await files.list(files.cwd)
// If currently editing the renamed file, close editor
if (editorPath.value === path) closeEditor()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to rename')
}
}
// ---- Row: Delete ----
async function confirmDelete(path: string) {
try {
await files.del(path)
toast.success('Deleted')
pendingDelete.value = null
await files.list(files.cwd)
if (editorPath.value === path) closeEditor()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to delete')
}
}
</script>
@@ -41,15 +189,232 @@ const finderConfig = {
</div>
</div>
<!-- VueFinder wrapper only the outer chrome is re-skinned; internals untouched -->
<div class="fm__finder">
<VueFinder
id="corrosion-filemanager"
:driver="driver"
:config="finderConfig"
locale="en"
/>
</div>
<!-- No instances at all -->
<Panel v-if="!instancesStore.loading && instancesStore.instances.length === 0">
<EmptyState
icon="server"
title="No host agent connected"
description="Install the host agent from the Server page to manage files on your game server."
>
<template #action>
<Button variant="secondary" size="sm" icon="server" @click="router.push('/server')">
Go to Server page
</Button>
</template>
</EmptyState>
</Panel>
<template v-else>
<!-- Instance selector -->
<div v-if="instancesStore.instances.length > 1" class="fm__instance-pick">
<span class="fm__field-label">Instance</span>
<select
class="fm__select"
:value="instancesStore.currentId ?? ''"
@change="onInstanceChange"
>
<option v-for="inst in instancesStore.instances" :key="inst.id" :value="inst.id">
{{ inst.label || inst.agent_instance_id }} ({{ inst.game }}) · {{ inst.host_hostname }}
</option>
</select>
</div>
<!-- File browser panel -->
<Panel :flush-body="true">
<template #title>
<!-- Breadcrumb -->
<div class="fm__breadcrumb">
<button
v-for="(crumb, i) in breadcrumbs"
:key="crumb.path"
class="fm__crumb"
:class="{ 'fm__crumb--active': i === breadcrumbs.length - 1 }"
:disabled="i === breadcrumbs.length - 1"
@click="navigate(crumb.path)"
>{{ crumb.label }}</button>
</div>
</template>
<template #actions>
<!-- Up button -->
<Button
v-if="parentPath !== null"
variant="ghost"
size="sm"
icon="chevron-left"
:disabled="files.loading"
@click="navigate(parentPath!)"
>
Up
</Button>
<Button
variant="ghost"
size="sm"
icon="folder-open"
:disabled="files.loading"
@click="newFolder"
>
New folder
</Button>
<Button
variant="ghost"
size="sm"
icon="file-text"
:disabled="files.loading"
@click="newFile"
>
New file
</Button>
<Button
variant="ghost"
size="sm"
icon="refresh-cw"
:disabled="files.loading"
:loading="files.loading"
@click="files.list(files.cwd)"
/>
</template>
<!-- Error state -->
<div v-if="files.error && !files.loading" class="fm__padded">
<Alert tone="danger" :title="files.error">
<template #actions>
<Button variant="danger-soft" size="sm" icon="refresh-cw" @click="files.list(files.cwd)">
Retry
</Button>
</template>
</Alert>
</div>
<!-- Loading skeleton -->
<div v-else-if="files.loading" class="fm__padded fm__loading">
<Icon name="loader" :size="18" :stroke-width="2" class="fm__spinner" />
<span class="fm__loading-text">Loading</span>
</div>
<!-- Empty directory -->
<EmptyState
v-else-if="sortedEntries.length === 0"
icon="folder-open"
title="Empty directory"
description="This directory contains no files or folders."
/>
<!-- Entry table -->
<table v-else class="fm__table">
<thead>
<tr>
<th class="fm__th fm__th--name">Name</th>
<th class="fm__th fm__th--size">Size</th>
<th class="fm__th fm__th--date">Modified</th>
<th class="fm__th fm__th--actions"></th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in sortedEntries"
:key="entry.path"
class="fm__row"
:class="{ 'fm__row--active': editorPath === entry.path }"
>
<!-- Name -->
<td class="fm__td fm__td--name">
<button
class="fm__entry-btn"
@click="entry.is_dir ? navigate(entry.path) : openFile(entry.path)"
>
<Icon
:name="entry.is_dir ? 'folder-open' : 'file-text'"
:size="15"
:stroke-width="1.75"
class="fm__entry-icon"
:class="entry.is_dir ? 'fm__entry-icon--dir' : 'fm__entry-icon--file'"
/>
<span class="fm__entry-name">{{ entry.name }}</span>
<Icon v-if="entry.is_dir" name="chevron-right" :size="13" :stroke-width="2" class="fm__entry-chevron" />
</button>
</td>
<!-- Size -->
<td class="fm__td fm__td--size">
{{ entry.is_dir ? '—' : safeFileSize(entry.size, '0 B') }}
</td>
<!-- Modified -->
<td class="fm__td fm__td--date">{{ safeDate(entry.modified) }}</td>
<!-- Row actions -->
<td class="fm__td fm__td--actions">
<!-- Pending delete confirm -->
<template v-if="pendingDelete === entry.path">
<span class="fm__del-confirm-label">Delete?</span>
<Button
variant="danger"
size="sm"
@click="confirmDelete(entry.path)"
>
Yes
</Button>
<Button
variant="ghost"
size="sm"
@click="pendingDelete = null"
>
Cancel
</Button>
</template>
<template v-else>
<Button
variant="ghost"
size="sm"
icon="pencil"
:title="`Rename ${entry.name}`"
@click="renameEntry(entry.path, entry.is_dir)"
/>
<Button
variant="ghost"
size="sm"
icon="trash-2"
:title="`Delete ${entry.name}`"
@click="pendingDelete = entry.path"
/>
</template>
</td>
</tr>
</tbody>
</table>
</Panel>
<!-- File editor panel -->
<Panel v-if="editorPath !== null" :title="editorPath">
<template #actions>
<Button
variant="primary"
size="sm"
icon="save"
:loading="editorSaving"
@click="saveFile"
>
Save
</Button>
<Button variant="ghost" size="sm" icon="x" @click="closeEditor">Close</Button>
</template>
<div v-if="editorLoading" class="fm__padded fm__loading">
<Icon name="loader" :size="18" :stroke-width="2" class="fm__spinner" />
<span class="fm__loading-text">Loading file</span>
</div>
<textarea
v-else
v-model="editorContent"
class="fm__editor"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</Panel>
</template>
</div>
</template>
@@ -76,12 +441,113 @@ const finderConfig = {
color: var(--text-primary); margin-top: 3px;
}
/* Finder container — surface panel chrome, VueFinder renders inside */
.fm__finder {
/* Instance selector */
.fm__instance-pick { display: flex; align-items: center; gap: 12px; }
.fm__field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-tertiary); }
.fm__select {
background: var(--surface-base);
border-radius: var(--radius-lg);
box-shadow: var(--ring-default);
overflow: hidden;
min-height: 640px;
color: var(--text-primary);
border: none;
box-shadow: var(--ring-subtle);
border-radius: var(--radius-md);
padding: 8px 12px;
font-size: var(--text-sm);
font-family: var(--font-mono);
min-width: 280px;
}
/* Breadcrumb */
.fm__breadcrumb { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
.fm__crumb {
background: none; border: none; cursor: pointer; padding: 0 4px;
font-size: var(--text-sm); font-weight: 500; color: var(--text-secondary);
border-radius: var(--radius-sm); transition: var(--transition-colors);
}
.fm__crumb:hover:not(:disabled) { color: var(--text-primary); background: var(--surface-hover); }
.fm__crumb:disabled { cursor: default; }
.fm__crumb--active { color: var(--text-primary); font-weight: 600; }
.fm__crumb:not(:last-child)::after { content: '/'; margin-left: 4px; color: var(--text-muted); }
/* Loading */
.fm__padded { padding: 24px 16px; }
.fm__loading { display: flex; align-items: center; gap: 10px; }
.fm__spinner { animation: fm-spin 0.75s linear infinite; color: var(--text-tertiary); }
@keyframes fm-spin { to { transform: rotate(360deg); } }
.fm__loading-text { font-size: var(--text-sm); color: var(--text-tertiary); }
/* Table */
.fm__table {
width: 100%; border-collapse: collapse;
font-size: var(--text-sm); table-layout: fixed;
}
.fm__th {
padding: 9px 14px; font-size: var(--text-xs); font-weight: 600;
color: var(--text-tertiary); text-align: left;
border-bottom: 1px solid var(--border-subtle);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
}
.fm__th--name { width: auto; }
.fm__th--size { width: 90px; }
.fm__th--date { width: 180px; }
.fm__th--actions { width: 130px; }
.fm__row { transition: background var(--dur-fast) var(--ease-standard); }
.fm__row:hover { background: var(--surface-hover); }
.fm__row--active { background: var(--accent-soft); }
.fm__row + .fm__row { border-top: 1px solid var(--border-subtle); }
.fm__td {
padding: 8px 14px; color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.fm__td--name { width: auto; }
.fm__td--size { text-align: right; font-family: var(--font-mono); font-size: var(--text-xs); }
.fm__td--date { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); }
.fm__td--actions {
text-align: right;
display: flex; align-items: center; justify-content: flex-end; gap: 2px;
padding-top: 6px; padding-bottom: 6px;
}
/* Entry button */
.fm__entry-btn {
display: inline-flex; align-items: center; gap: 8px;
background: none; border: none; cursor: pointer; padding: 0;
color: var(--text-primary); font-size: var(--text-sm); font-weight: 500;
max-width: 100%; text-align: left;
}
.fm__entry-btn:hover .fm__entry-name { text-decoration: underline; text-decoration-color: var(--border-subtle); }
.fm__entry-icon--dir { color: var(--accent); }
.fm__entry-icon--file { color: var(--text-tertiary); }
.fm__entry-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--font-mono); font-size: var(--text-xs);
}
.fm__entry-chevron { color: var(--text-muted); flex: none; }
/* Inline delete confirm */
.fm__del-confirm-label {
font-size: var(--text-xs); font-weight: 600; color: var(--danger);
margin-right: 6px;
}
/* File editor textarea */
.fm__editor {
width: 100%;
min-height: 400px;
background: var(--surface-base);
color: var(--text-primary);
border: none;
box-shadow: var(--ring-subtle);
border-radius: var(--radius-md);
padding: 14px 16px;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
resize: vertical;
outline: none;
display: block;
}
.fm__editor:focus { box-shadow: var(--focus-ring); }
</style>