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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user