feat(files): native instance-scoped file browser (replaces broken VueFinder)
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:
136
frontend/src/stores/files.ts
Normal file
136
frontend/src/stores/files.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user