Skip to content

Commit bd3bdb3

Browse files
antfubotopencode
andcommitted
feat(assets): resizable wider details panel, direct upload, drop the upload modal
- Details panel is now a resizable right-hand side panel (default 480px, drag its left edge; width persists to localStorage) instead of a fixed w-96 column. - Upload button opens the native file picker directly and uploads the chosen files immediately — the file-grid/confirm modal is gone. Whole-frame drag-and-drop still works, now uploading dropped files directly with a lightweight non-blocking hint overlay (useFileDrop). - Remove the 'Edit' action from the details panel, and with it the now-orphaned write-text RPC (its only consumer). Text files still preview via read-text. Docs + API snapshots updated; the registration test no longer invokes the external-launching RPCs (which spawned OS processes and hung the runner) — it asserts registration on the server context instead. Co-authored-by: opencode <noreply@opencode.ai>
1 parent 78a5f68 commit bd3bdb3

10 files changed

Lines changed: 160 additions & 504 deletions

File tree

docs/plugins/assets.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Package: `@devframes/plugin-assets` · framework: **Preact**
1010

1111
## What it does
1212

13-
Search by name and filter by type from an inline chip row, switch between a thumbnail grid (grouped by folder) and a file tree, and open a right-hand details panel with a live preview (image, video, audio, font, or text), file metadata, and ready-to-copy usage snippets (`<img>`, CSS `background-image`, `@font-face`, a download link). Drop files anywhere on the frame to upload them, or select multiple assets to delete them together. A live file watcher keeps every connected client's listing in sync with changes made outside the UI.
13+
Search by name and filter by type from an inline chip row, switch between a thumbnail grid (grouped by folder) and a file tree, and open a resizable right-hand details panel with a live preview (image, video, audio, font, or text), file metadata, and ready-to-copy usage snippets (`<img>`, CSS `background-image`, `@font-face`, a download link). Upload files with the toolbar button (native file picker) or by dropping them anywhere on the frame, and select multiple assets to delete them together. A live file watcher keeps every connected client's listing in sync with changes made outside the UI.
1414

1515
The standalone server requires devframe's trust handshake by default because it can read, write, and delete real files. Uploads, renames, deletes, and folder creation are enabled by default — pass `{ write: false }` (or `--read-only` on the standalone CLI) for a browse-only deployment.
1616

@@ -72,15 +72,14 @@ All functions are namespaced `devframes:plugin:assets:*`:
7272
| `list` | `query`, `snapshot: true` | Every file under the managed directory, with type, size, and last-modified time. |
7373
| `capabilities` | `query`, `snapshot: true` | Whether write actions are enabled, and the upload allow-list — lets the UI gate itself proactively. |
7474
| `read-image-meta` | `query` | Width, height, and orientation for an image asset. |
75-
| `read-text` | `query` | Truncated text content, for preview or editing. |
75+
| `read-text` | `query` | Truncated text content, for preview. |
7676
| `upload` | `action` | Allocates a streaming upload slot; the client pipes the file's bytes over the paired channel. |
7777
| `rename` | `action` | Renames an asset within its folder, preserving its extension. |
7878
| `delete` | `action` | Deletes one or more assets in a single call. |
7979
| `mkdir` | `action` | Creates a folder, including missing parents. |
80-
| `write-text` | `action` | Overwrites a text asset's content in place (the details panel's inline editor). |
8180
| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager. Always registered, regardless of `write`. |
8281

83-
`upload` / `rename` / `delete` / `mkdir` / `write-text` are registered only when `write` is enabled.
82+
`upload` / `rename` / `delete` / `mkdir` are registered only when `write` is enabled.
8483

8584
## Static export
8685

plugins/assets/src/rpc/functions/write-text.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.

plugins/assets/src/rpc/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { readText } from './functions/read-text'
99
import { rename } from './functions/rename'
1010
import { revealInFolder } from './functions/reveal-in-folder'
1111
import { upload } from './functions/upload'
12-
import { writeText } from './functions/write-text'
1312

1413
/** Read-only RPC — always registered. */
1514
export const readFunctions = [list, readImageMeta, readText, capabilities] as const
@@ -21,7 +20,7 @@ export const readFunctions = [list, readImageMeta, readText, capabilities] as co
2120
export const alwaysFunctions = [openInEditor, revealInFolder] as const
2221

2322
/** Mutating RPC — registered only when write actions are enabled. */
24-
export const writeFunctions = [upload, rename, deleteAssets, mkdir, writeText] as const
23+
export const writeFunctions = [upload, rename, deleteAssets, mkdir] as const
2524

2625
export const serverFunctions = [...readFunctions, ...alwaysFunctions, ...writeFunctions] as const
2726

@@ -41,4 +40,3 @@ export type { RenameArgs } from './functions/rename'
4140
export { rename } from './functions/rename'
4241
export { revealInFolder } from './functions/reveal-in-folder'
4342
export { upload, UPLOAD_CHANNEL } from './functions/upload'
44-
export { writeText } from './functions/write-text'

plugins/assets/src/spa/app/App.tsx

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { AssetInfo, AssetType } from '../../types'
2-
import { useMemo, useState } from 'preact/hooks'
2+
import { useMemo, useRef, useState } from 'preact/hooks'
33
import { AssetDetails } from './components/AssetDetails'
44
import { AssetGrid } from './components/AssetGrid'
55
import { AssetTree } from './components/AssetTree'
6-
import { DropZone } from './components/DropZone'
76
import { Toolbar } from './components/Toolbar'
87
import { TypeFilter } from './components/TypeFilter'
98
import { Badge } from './components/ui/Badge'
@@ -12,28 +11,69 @@ import { Dialog } from './components/ui/Dialog'
1211
import { TextInput } from './components/ui/TextInput'
1312
import { connectionBody, connectionGlyph, connectionPanel, connectionState, connectionTitle, nav, navBrand } from './design'
1413
import { useAssets } from './hooks/useAssets'
14+
import { useFileDrop } from './hooks/useFileDrop'
1515
import { useLocalStorage } from './hooks/useLocalStorage'
1616
import { useUpload } from './hooks/useUpload'
1717
import { ASSET_TYPES } from './utils/assetType'
1818

1919
type ViewMode = 'grid' | 'list'
2020

21+
const MIN_PANEL_WIDTH = 320
22+
const MAX_PANEL_WIDTH = 900
23+
const DEFAULT_PANEL_WIDTH = 480
24+
2125
export function App() {
2226
const { assets, capabilities, loading, error, isStatic, refresh, rpc } = useAssets()
2327
const [view, setView] = useLocalStorage<ViewMode>('devframes:plugin:assets:view', 'grid')
28+
const [panelWidth, setPanelWidth] = useLocalStorage<number>('devframes:plugin:assets:panelWidth', DEFAULT_PANEL_WIDTH)
2429
const [typeState, setTypeState] = useState<Partial<Record<AssetType, boolean>>>({})
2530
const [search, setSearch] = useState('')
2631
const [selected, setSelected] = useState<AssetInfo | undefined>()
2732
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
28-
const [dropzoneOpen, setDropzoneOpen] = useState(false)
2933
const [mkdirOpen, setMkdirOpen] = useState(false)
3034
const [newFolderName, setNewFolderName] = useState('')
3135
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false)
36+
const fileInputRef = useRef<HTMLInputElement>(null)
3237

3338
const { uploading, errors: uploadErrors, uploadFiles } = useUpload(rpc, refresh)
3439

3540
const canWrite = capabilities?.write ?? false
3641

42+
function uploadSelected(files: FileList): void {
43+
void uploadFiles(Array.from(files).map(file => ({ file, targetPath: file.name })))
44+
}
45+
46+
const { dragging } = useFileDrop(canWrite, uploadSelected)
47+
48+
function onFilePick(e: Event): void {
49+
const input = e.target as HTMLInputElement
50+
if (input.files?.length)
51+
uploadSelected(input.files)
52+
// Reset so picking the same file again still fires `change`.
53+
input.value = ''
54+
}
55+
56+
function startResize(e: PointerEvent): void {
57+
e.preventDefault()
58+
const startX = e.clientX
59+
const startWidth = panelWidth
60+
document.body.style.userSelect = 'none'
61+
document.body.style.cursor = 'col-resize'
62+
function onMove(ev: PointerEvent): void {
63+
// Panel is on the right, so dragging its left edge leftwards widens it.
64+
const next = Math.min(Math.max(startWidth + (startX - ev.clientX), MIN_PANEL_WIDTH), MAX_PANEL_WIDTH)
65+
setPanelWidth(next)
66+
}
67+
function onUp(): void {
68+
document.body.style.userSelect = ''
69+
document.body.style.cursor = ''
70+
window.removeEventListener('pointermove', onMove)
71+
window.removeEventListener('pointerup', onUp)
72+
}
73+
window.addEventListener('pointermove', onMove)
74+
window.addEventListener('pointerup', onUp)
75+
}
76+
3777
// Types present in the listing, with counts, in canonical display order.
3878
const typeItems = useMemo(() => {
3979
const counts = new Map<AssetType, number>()
@@ -104,6 +144,8 @@ export function App() {
104144
)
105145
}
106146

147+
const banner = error ?? (uploadErrors.length ? uploadErrors.join(' · ') : null)
148+
107149
return (
108150
<div class="flex h-screen flex-col bg-base color-base">
109151
<header class={nav('gap-3')}>
@@ -113,6 +155,7 @@ export function App() {
113155
</span>
114156
{isStatic && <Badge variant="secondary">static</Badge>}
115157
{!canWrite && !loading && <Badge variant="outline">read-only</Badge>}
158+
{uploading && <Badge variant="primary">uploading…</Badge>}
116159
<Toolbar
117160
search={search}
118161
onSearchChange={setSearch}
@@ -121,7 +164,7 @@ export function App() {
121164
total={assets?.length ?? 0}
122165
filtered={filtered.length}
123166
canWrite={canWrite}
124-
onUpload={() => setDropzoneOpen(true)}
167+
onUpload={() => fileInputRef.current?.click()}
125168
onNewFolder={() => setMkdirOpen(true)}
126169
selectedCount={selectedPaths.size}
127170
onBulkDelete={() => setBulkDeleteOpen(true)}
@@ -131,8 +174,8 @@ export function App() {
131174

132175
<TypeFilter items={typeItems} onToggle={toggleType} />
133176

134-
{error && (
135-
<div class="shrink-0 border-b border-base bg-error/10 px-3 py-1 text-xs text-error">{error}</div>
177+
{banner && (
178+
<div class="shrink-0 border-b border-base bg-error/10 px-3 py-1 text-xs text-error">{banner}</div>
136179
)}
137180

138181
<div class="flex min-h-0 flex-1">
@@ -164,27 +207,43 @@ export function App() {
164207
</main>
165208

166209
{selected && (
167-
<aside class="min-h-0 w-96 shrink-0 overflow-y-auto border-l border-base bg-base">
168-
<AssetDetails
169-
asset={selected}
170-
rpc={rpc}
171-
canWrite={canWrite}
172-
onClose={() => setSelected(undefined)}
173-
onChanged={() => void refresh()}
210+
<aside
211+
class="relative min-h-0 shrink-0 border-l border-base bg-base"
212+
style={{ width: `${panelWidth}px` }}
213+
>
214+
{/* Drag handle to resize the panel. */}
215+
<div
216+
class="absolute left-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 cursor-col-resize hover:bg-active"
217+
onPointerDown={startResize}
218+
role="separator"
219+
aria-orientation="vertical"
220+
aria-label="Resize details panel"
174221
/>
222+
<div class="min-h-0 h-full overflow-y-auto">
223+
<AssetDetails
224+
asset={selected}
225+
rpc={rpc}
226+
canWrite={canWrite}
227+
onClose={() => setSelected(undefined)}
228+
onChanged={() => void refresh()}
229+
/>
230+
</div>
175231
</aside>
176232
)}
177233
</div>
178234

179-
<DropZone
180-
open={dropzoneOpen}
181-
onClose={() => setDropzoneOpen(false)}
182-
enabled={canWrite}
183-
folder=""
184-
uploading={uploading}
185-
errors={uploadErrors}
186-
onUpload={uploadFiles}
187-
/>
235+
{/* Direct file picker for the Upload button — no modal. */}
236+
<input ref={fileInputRef} type="file" multiple class="hidden" onChange={onFilePick} />
237+
238+
{/* Non-blocking hint while files are dragged over the frame. */}
239+
{dragging && (
240+
<div class="pointer-events-none fixed inset-0 z-drawer-content flex items-center justify-center bg-base/80 backdrop-blur-sm">
241+
<div class="flex flex-col items-center gap-2 rounded-xl border-2 border-dashed border-active px-10 py-8 text-lg color-active">
242+
<span class="i-ph-cloud-arrow-up-duotone text-3xl" />
243+
<span>Drop files to upload</span>
244+
</div>
245+
</div>
246+
)}
188247

189248
<Dialog open={mkdirOpen} onClose={() => setMkdirOpen(false)}>
190249
<TextInput

plugins/assets/src/spa/app/components/AssetDetails.tsx

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,7 @@ export function AssetDetails({ asset, rpc, canWrite, onClose, onChanged }: Asset
3030
const [textContent, setTextContent] = useState<string | null>(null)
3131
const [deleteOpen, setDeleteOpen] = useState(false)
3232
const [renameOpen, setRenameOpen] = useState(false)
33-
const [editOpen, setEditOpen] = useState(false)
3433
const [newName, setNewName] = useState('')
35-
const [draftContent, setDraftContent] = useState('')
3634
const [busy, setBusy] = useState(false)
3735
const [notice, setNotice] = useState<{ kind: 'success' | 'error', message: string } | null>(null)
3836

@@ -44,12 +42,8 @@ export function AssetDetails({ asset, rpc, canWrite, onClose, onChanged }: Asset
4442
return
4543
if (asset.type === 'image')
4644
void rpc.call('devframes:plugin:assets:read-image-meta', asset.path).then(setImageMeta)
47-
if (asset.type === 'text') {
48-
void rpc.call('devframes:plugin:assets:read-text', asset.path, 5000).then((content) => {
49-
setTextContent(content)
50-
setDraftContent(content ?? '')
51-
})
52-
}
45+
if (asset.type === 'text')
46+
void rpc.call('devframes:plugin:assets:read-text', asset.path, 5000).then(setTextContent)
5347
}, [rpc, asset.path, asset.type])
5448

5549
const aspectRatio = (() => {
@@ -95,15 +89,6 @@ export function AssetDetails({ asset, rpc, canWrite, onClose, onChanged }: Asset
9589
}, 'Renamed')
9690
}
9791

98-
async function handleSaveText(): Promise<void> {
99-
await withNotice(async () => {
100-
await rpc!.call('devframes:plugin:assets:write-text', { path: asset.path, content: draftContent })
101-
setEditOpen(false)
102-
setTextContent(draftContent)
103-
onChanged()
104-
}, 'Saved')
105-
}
106-
10792
async function handleOpenInEditor(): Promise<void> {
10893
await withNotice(async () => {
10994
await rpc!.call('devframes:plugin:assets:open-in-editor', asset.path)
@@ -184,13 +169,6 @@ export function AssetDetails({ asset, rpc, canWrite, onClose, onChanged }: Asset
184169
{' '}
185170
Reveal in Folder
186171
</Button>
187-
{canWrite && asset.type === 'text' && (
188-
<Button variant="secondary" size="sm" onClick={() => setEditOpen(true)}>
189-
<span class="i-ph-pencil-simple-duotone" />
190-
{' '}
191-
Edit
192-
</Button>
193-
)}
194172
{canWrite && (
195173
<Button variant="secondary" size="sm" onClick={() => openRenameDialog()}>
196174
<span class="i-ph-text-aa-duotone" />
@@ -240,18 +218,6 @@ export function AssetDetails({ asset, rpc, canWrite, onClose, onChanged }: Asset
240218
<Button variant="primary" onClick={handleRename} disabled={busy || !newName.trim()}>Rename</Button>
241219
</div>
242220
</Dialog>
243-
244-
<Dialog open={editOpen} onClose={() => setEditOpen(false)} class="max-w-2xl">
245-
<textarea
246-
value={draftContent}
247-
onInput={e => setDraftContent((e.target as HTMLTextAreaElement).value)}
248-
class="h-80 w-full rounded border border-base bg-base p-3 font-mono text-sm outline-none"
249-
/>
250-
<div class="flex justify-end gap-2">
251-
<Button variant="ghost" onClick={() => setEditOpen(false)}>Cancel</Button>
252-
<Button variant="primary" onClick={handleSaveText} disabled={busy}>Save</Button>
253-
</div>
254-
</Dialog>
255221
</div>
256222
)
257223
}

0 commit comments

Comments
 (0)