Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions electron/main/artifact-registry-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normalizeWorkspaceAssetPath,
openWorkspaceAssetLibraryEntry,
readWorkspaceAssetLibraryEntry,
readWorkspaceAssetLibraryThumbnail,
registerWorkspaceAssetLibraryIpcHandlers,
} from './artifact-registry-service.ts'

Expand Down Expand Up @@ -198,6 +199,27 @@ test('fails closed for unsafe, self, missing, and mismatched sourceWorkspacePath
assert.equal(!mismatched.success && mismatched.error.code, 'not-openable')
}))

test('reads a sibling .thumb.png for GLB assets and stays silent when one is missing', () => withWorkspace(async (workspaceDir) => {
await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true })
await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes')
await writeFile(path.join(workspaceDir, 'Exports/props/no-thumb.glb'), 'glb')
await writeFile(path.join(workspaceDir, 'Exports/static.ply'), 'ply')

const withThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' })
assert.equal(withThumb.success, true)
assert.equal(withThumb.success && withThumb.dataUrl, `data:image/png;base64,${Buffer.from('fake-png-bytes').toString('base64')}`)

const withoutThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/no-thumb.glb' })
assert.equal(withoutThumb.success, false)

const nonMesh = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/static.ply' })
assert.equal(nonMesh.success, false)

const unsafe = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: '../secret.glb' })
assert.equal(unsafe.success, false)
}))

test('IPC read and open handlers forward sourceWorkspacePath without trusting malformed payloads', async () => {
const handlers = new Map<string, (event: unknown, payload: unknown) => Promise<unknown>>()
registerWorkspaceAssetLibraryIpcHandlers({
Expand All @@ -224,7 +246,12 @@ test('registers workspace library IPC handlers with structured results', async (
assert.equal(typeof handlers.get('workspace:library:list'), 'function')
assert.equal(typeof handlers.get('workspace:library:read'), 'function')
assert.equal(typeof handlers.get('workspace:library:open'), 'function')
assert.equal(typeof handlers.get('workspace:library:thumbnail'), 'function')
const result = await handlers.get('workspace:library:read')?.({}, { workspacePath: '../escape.glb' })
assert.equal(typeof (result as { success?: unknown }).success, 'boolean')
assert.equal((result as { success: boolean, error?: { code: string } }).error?.code, 'unsafe-path')
const thumbnail = await handlers.get('workspace:library:thumbnail')?.({}, { workspacePath: '../escape.glb' })
assert.equal((thumbnail as { success: boolean }).success, false)
const missingPayload = await handlers.get('workspace:library:thumbnail')?.({}, {})
assert.equal((missingPayload as { success: boolean }).success, false)
})
25 changes: 25 additions & 0 deletions electron/main/artifact-registry-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
AssetLibraryPreviewPayload,
AssetLibraryReadResult,
AssetLibrarySourceScope,
AssetLibraryThumbnailResult,
} from '../../src/shared/types/assetLibrary'
import type { ArtifactProvenance } from '../../src/shared/types/artifacts'

Expand Down Expand Up @@ -51,6 +52,10 @@ export interface WorkspaceAssetLibraryReadRequest extends WorkspaceAssetLibraryR
sourceWorkspacePath?: string
}

export interface WorkspaceAssetLibraryThumbnailRequest extends WorkspaceAssetLibraryRequest {
workspacePath: string
}

interface AssetLibraryMetadata {
sourceWorkspacePath?: string
manifestWorkspacePath?: string
Expand Down Expand Up @@ -342,6 +347,21 @@ export async function openWorkspaceAssetLibraryEntry(request: WorkspaceAssetLibr
return { success: true, entry: read.entry }
}

// Thumbnails are pre-rendered beside their source mesh (see thumbs.py): a
// `<name>.glb` asset may have a sibling `<name>.thumb.png`. Missing files are
// a normal, silent condition, never surfaced as an AssetLibraryError.
export async function readWorkspaceAssetLibraryThumbnail(request: WorkspaceAssetLibraryThumbnailRequest): Promise<AssetLibraryThumbnailResult> {
try {
const normalized = normalizeWorkspaceAssetPath(request.workspaceDir, request.workspacePath)
if (!isGlbOrGltf(normalized.workspacePath)) return { success: false }
const thumbnailAbsolutePath = normalized.absolutePath.replace(/\.(glb|gltf)$/i, '.thumb.png')
const buffer = await readFile(thumbnailAbsolutePath)
return { success: true, dataUrl: `data:image/png;base64,${buffer.toString('base64')}` }
} catch {
return { success: false }
}
}

function readPayloadRequest(payload: unknown): { workspacePath?: string, sourceWorkspacePath?: string } {
if (typeof payload !== 'object' || payload === null) return {}
const values = payload as { workspacePath?: unknown, sourceWorkspacePath?: unknown }
Expand All @@ -363,4 +383,9 @@ export function registerWorkspaceAssetLibraryIpcHandlers(deps: WorkspaceAssetLib
if (!workspacePath) return { success: false, error: libraryError('invalid-request', 'workspacePath is required.') }
return openWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), workspacePath, sourceWorkspacePath })
})
deps.ipcMain.handle('workspace:library:thumbnail', async (_event, payload) => {
const { workspacePath } = readPayloadRequest(payload)
if (!workspacePath) return { success: false }
return readWorkspaceAssetLibraryThumbnail({ workspaceDir: deps.getWorkspaceDir(), workspacePath })
})
}
2 changes: 2 additions & 0 deletions electron/preload/artifact-registry-preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ test('preload exposes scoped workspace library list/read/open methods', async ()
workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json',
sourceWorkspacePath: 'Workflows/checkpoints/hero.glb',
})
await api.workspace.library.thumbnail({ workspacePath: 'Exports/hero.glb' })

assert.deepEqual(calls, [
{ channel: 'workspace:library:list', payload: undefined },
Expand All @@ -41,5 +42,6 @@ test('preload exposes scoped workspace library list/read/open methods', async ()
sourceWorkspacePath: 'Workflows/checkpoints/hero.glb',
},
},
{ channel: 'workspace:library:thumbnail', payload: { workspacePath: 'Exports/hero.glb' } },
])
})
3 changes: 3 additions & 0 deletions electron/preload/electron-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {
AssetLibraryOpenResult,
AssetLibraryReadRequest,
AssetLibraryReadResult,
AssetLibraryThumbnailRequest,
AssetLibraryThumbnailResult,
} from '../../src/shared/types/assetLibrary'

export interface IpcRendererLike {
Expand Down Expand Up @@ -189,6 +191,7 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra
list: (): Promise<AssetLibraryListResult> => ipcRenderer.invoke('workspace:library:list') as Promise<AssetLibraryListResult>,
read: (request: AssetLibraryReadRequest): Promise<AssetLibraryReadResult> => ipcRenderer.invoke('workspace:library:read', request) as Promise<AssetLibraryReadResult>,
open: (request: AssetLibraryOpenRequest): Promise<AssetLibraryOpenResult> => ipcRenderer.invoke('workspace:library:open', request) as Promise<AssetLibraryOpenResult>,
thumbnail: (request: AssetLibraryThumbnailRequest): Promise<AssetLibraryThumbnailResult> => ipcRenderer.invoke('workspace:library:thumbnail', request) as Promise<AssetLibraryThumbnailResult>,
},
},

Expand Down
82 changes: 76 additions & 6 deletions src/areas/generate/GeneratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from '
import {
ASSET_LIBRARY_SORT_OPTIONS,
buildAssetLibraryOpenRequest,
clampAssetLibraryPanelWidth,
createAssetLibraryOpenJob,
describeAssetLibraryOpenability,
filterAssetLibraryScopeGroups,
getDefaultAssetLibraryCollapsedSectionKeys,
getStoredAssetLibraryPanelWidth,
isAssetLibraryEntryOpenable,
resolveOpenPanelAfterLibrarySelection,
storeAssetLibraryPanelWidth,
toggleAssetLibrarySectionKey,
type AssetLibrarySortMode,
type GenerateOpenPanel,
Expand Down Expand Up @@ -368,13 +371,16 @@ function AssetLibraryPopover({
searchQuery,
sortMode,
collapsedSectionKeys,
thumbnails,
panelWidth,
onSelectEntry,
onSearchQueryChange,
onSortModeChange,
onToggleSection,
onOpenSelected,
onRefresh,
onClose,
onResizeMouseDown,
}: {
entries: ProjectedAssetLibraryEntry[]
selectedEntryId: string | null
Expand All @@ -384,13 +390,17 @@ function AssetLibraryPopover({
searchQuery: string
sortMode: AssetLibrarySortMode
collapsedSectionKeys: string[]
/** Data URLs keyed by workspacePath, populated lazily as thumbnails load. */
thumbnails: Record<string, string>
panelWidth: number
onSelectEntry: (entryId: string) => void
onSearchQueryChange: (value: string) => void
onSortModeChange: (value: AssetLibrarySortMode) => void
onToggleSection: (sectionKey: string) => void
onOpenSelected: () => void
onRefresh: () => void
onClose: () => void
onResizeMouseDown: (event: React.MouseEvent) => void
}) {
const scopeGroups = filterAssetLibraryScopeGroups(entries, searchQuery, sortMode)
const visibleEntryIds = new Set(scopeGroups.flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries.map((entry) => entry.id))))
Expand All @@ -409,7 +419,8 @@ function AssetLibraryPopover({
<div
role="dialog"
aria-label="Workspace library"
className="absolute top-full left-0 mt-1 z-50 w-[320px] max-w-[calc(100vw-2rem)] bg-zinc-900 border border-zinc-700/60 rounded-xl p-3 flex flex-col gap-3 shadow-xl"
style={{ width: panelWidth }}
className="absolute top-full left-0 mt-1 z-50 max-w-[calc(100vw-2rem)] bg-zinc-900 border border-zinc-700/60 rounded-xl p-3 flex flex-col gap-3 shadow-xl"
>
<div className="flex items-center justify-between gap-2">
<div>
Expand All @@ -425,6 +436,15 @@ function AssetLibraryPopover({
</button>
</div>

{/* Resize handle — drag to widen/narrow the library panel; size is persisted. */}
<div
onMouseDown={onResizeMouseDown}
role="separator"
aria-orientation="vertical"
aria-label="Resize workspace library panel"
className="absolute top-0 right-0 bottom-0 w-1.5 cursor-col-resize hover:bg-violet-400/40 active:bg-violet-400/60 transition-colors rounded-r-xl"
/>

<button
type="button"
onClick={onRefresh}
Expand Down Expand Up @@ -505,6 +525,7 @@ function AssetLibraryPopover({
<div id={capabilityRegionId}>
{group.entries.map((entry) => {
const selected = entry.id === selectedEntryId
const thumbnailDataUrl = thumbnails[entry.workspacePath]
return (
<button
key={entry.id}
Expand All @@ -513,14 +534,23 @@ function AssetLibraryPopover({
aria-pressed={selected}
aria-label={`Select library asset ${entry.displayName}`}
onClick={() => onSelectEntry(entry.id)}
className={`w-full text-left px-4 py-2 border-t border-zinc-800 first:border-t-0 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400
className={`flex w-full items-center gap-3 text-left px-4 py-2 border-t border-zinc-800 first:border-t-0 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400
${selected ? 'bg-violet-500/10 text-zinc-100' : 'text-zinc-300 hover:bg-zinc-800/80'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{entry.displayName}</span>
<span className="text-[10px] uppercase tracking-wider text-zinc-500">{entry.capability ?? entry.state.replace(/-/g, ' ')}</span>
{thumbnailDataUrl && (
<img
src={thumbnailDataUrl}
alt=""
className="h-20 w-20 shrink-0 rounded-lg border border-zinc-800 bg-zinc-950 object-contain"
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{entry.displayName}</span>
<span className="text-[10px] uppercase tracking-wider text-zinc-500">{entry.capability ?? entry.state.replace(/-/g, ' ')}</span>
</div>
<p className="mt-1 truncate text-[10px] text-zinc-500">{entry.workspacePath}</p>
</div>
<p className="mt-1 truncate text-[10px] text-zinc-500">{entry.workspacePath}</p>
</button>
)
})}
Expand Down Expand Up @@ -575,8 +605,11 @@ export default function GeneratePage(): JSX.Element {
const [librarySearchQuery, setLibrarySearchQuery] = useState('')
const [librarySortMode, setLibrarySortMode] = useState<AssetLibrarySortMode>('type')
const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState<string[]>(() => getDefaultAssetLibraryCollapsedSectionKeys())
const [libraryThumbnails, setLibraryThumbnails] = useState<Record<string, string>>({})
const [libraryPanelWidth, setLibraryPanelWidth] = useState<number>(() => getStoredAssetLibraryPanelWidth())
const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null)
const dragging = useRef(false)
const libraryPanelDragging = useRef(false)
// Populated by Viewer3D — undoes the latest live gizmo transform, if any.
const gizmoUndoRef = useRef<(() => boolean) | null>(null)

Expand Down Expand Up @@ -640,6 +673,11 @@ export default function GeneratePage(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps -- lazy-load guarded by loaded/loading flags
}, [openPanel, libraryLoaded, libraryLoading])

// Persist the library panel's width so a manual resize survives app restarts.
useEffect(() => {
storeAssetLibraryPanelWidth(libraryPanelWidth)
}, [libraryPanelWidth])

async function handleUnloadAll() {
await window.electron.model.unloadAll()
setUnloadStatus('done')
Expand Down Expand Up @@ -710,6 +748,7 @@ export default function GeneratePage(): JSX.Element {
? current
: result.entries.find(isAssetLibraryEntryOpenable)?.id ?? result.entries[0]?.id ?? null)
setLibraryLoaded(true)
void loadLibraryThumbnails(result.entries)
} catch (err) {
setLibraryLoaded(false)
setLibraryEntries([])
Expand All @@ -720,6 +759,17 @@ export default function GeneratePage(): JSX.Element {
}
}

// Thumbnails are best-effort: only .glb/.gltf entries can have a rendered
// .thumb.png, and a missing one is silently skipped rather than surfaced.
async function loadLibraryThumbnails(entries: ProjectedAssetLibraryEntry[]) {
const candidates = entries.filter((entry) => entry.previewKind === '3d-model')
const results = await Promise.all(candidates.map(async (entry) => {
const result = await assetLibraryService.thumbnail({ workspacePath: entry.workspacePath })
return [entry.workspacePath, result.success ? result.dataUrl : null] as const
}))
setLibraryThumbnails(Object.fromEntries(results.filter((pair): pair is [string, string] => pair[1] !== null)))
}

async function handleOpenSelectedLibraryEntry() {
const selectedEntry = libraryEntries.find((entry) => entry.id === librarySelectedEntryId) ?? null
if (!selectedEntry) {
Expand Down Expand Up @@ -806,6 +856,23 @@ export default function GeneratePage(): JSX.Element {
window.addEventListener('mouseup', onMouseUp)
}, [])

const onLibraryPanelResizeMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
libraryPanelDragging.current = true

const onMouseMove = (ev: MouseEvent) => {
if (!libraryPanelDragging.current) return
setLibraryPanelWidth((w) => clampAssetLibraryPanelWidth(w + ev.movementX))
}
const onMouseUp = () => {
libraryPanelDragging.current = false
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}, [])

return (
<>
<div className="flex flex-col border-r border-zinc-800 bg-surface-400 overflow-hidden shrink-0" style={{ width: panelWidth }}>
Expand Down Expand Up @@ -929,6 +996,8 @@ export default function GeneratePage(): JSX.Element {
searchQuery={librarySearchQuery}
sortMode={librarySortMode}
collapsedSectionKeys={libraryCollapsedSectionKeys}
thumbnails={libraryThumbnails}
panelWidth={libraryPanelWidth}
onSelectEntry={(entryId) => {
setLibraryError(null)
setLibrarySelectedEntryId(entryId)
Expand All @@ -939,6 +1008,7 @@ export default function GeneratePage(): JSX.Element {
onOpenSelected={() => { void handleOpenSelectedLibraryEntry() }}
onRefresh={() => { void loadLibraryEntries() }}
onClose={() => setOpenPanel(null)}
onResizeMouseDown={onLibraryPanelResizeMouseDown}
/>
)}
</div>
Expand Down
19 changes: 19 additions & 0 deletions src/areas/generate/assetLibraryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ test('renderer service delegates safe IPC calls and normalizes returned entries'
}),
read: async () => ({ success: false, error: { code: 'missing', message: 'Missing' } }),
open: async (request) => { openRequest = request; return { success: false, error: { code: 'missing', message: 'Missing' } } },
thumbnail: async () => ({ success: false }),
})

const result = await service.list()
Expand All @@ -58,3 +59,21 @@ test('renderer service delegates safe IPC calls and normalizes returned entries'
await service.open({ workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' })
assert.deepEqual(openRequest, { workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' })
})

test('renderer service validates thumbnail requests before IPC and never surfaces an error', async () => {
let invoked = false
const service = createAssetLibraryService({
list: async () => ({ success: true, entries: [] }),
read: async () => ({ success: false, error: { code: 'unexpected', message: 'should not run' } }),
open: async () => ({ success: false, error: { code: 'unexpected', message: 'should not run' } }),
thumbnail: async () => { invoked = true; return { success: true, dataUrl: 'data:image/png;base64,ok' } },
})

const unsafe = await service.thumbnail({ workspacePath: '../escape.glb' })
assert.deepEqual(unsafe, { success: false })
assert.equal(invoked, false)

const safe = await service.thumbnail({ workspacePath: 'Exports/hero.glb' })
assert.deepEqual(safe, { success: true, dataUrl: 'data:image/png;base64,ok' })
assert.equal(invoked, true)
})
Loading