diff --git a/electron/main/artifact-registry-service.test.ts b/electron/main/artifact-registry-service.test.ts index 882120d4..1e01df29 100644 --- a/electron/main/artifact-registry-service.test.ts +++ b/electron/main/artifact-registry-service.test.ts @@ -10,6 +10,7 @@ import { normalizeWorkspaceAssetPath, openWorkspaceAssetLibraryEntry, readWorkspaceAssetLibraryEntry, + readWorkspaceAssetLibraryThumbnail, registerWorkspaceAssetLibraryIpcHandlers, } from './artifact-registry-service.ts' @@ -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 Promise>() registerWorkspaceAssetLibraryIpcHandlers({ @@ -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) }) diff --git a/electron/main/artifact-registry-service.ts b/electron/main/artifact-registry-service.ts index 3c26de73..469bf23e 100644 --- a/electron/main/artifact-registry-service.ts +++ b/electron/main/artifact-registry-service.ts @@ -12,6 +12,7 @@ import type { AssetLibraryPreviewPayload, AssetLibraryReadResult, AssetLibrarySourceScope, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' import type { ArtifactProvenance } from '../../src/shared/types/artifacts' @@ -51,6 +52,10 @@ export interface WorkspaceAssetLibraryReadRequest extends WorkspaceAssetLibraryR sourceWorkspacePath?: string } +export interface WorkspaceAssetLibraryThumbnailRequest extends WorkspaceAssetLibraryRequest { + workspacePath: string +} + interface AssetLibraryMetadata { sourceWorkspacePath?: string manifestWorkspacePath?: string @@ -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 +// `.glb` asset may have a sibling `.thumb.png`. Missing files are +// a normal, silent condition, never surfaced as an AssetLibraryError. +export async function readWorkspaceAssetLibraryThumbnail(request: WorkspaceAssetLibraryThumbnailRequest): Promise { + 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 } @@ -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 }) + }) } diff --git a/electron/preload/artifact-registry-preload.test.ts b/electron/preload/artifact-registry-preload.test.ts index 511589e4..fd85a9a6 100644 --- a/electron/preload/artifact-registry-preload.test.ts +++ b/electron/preload/artifact-registry-preload.test.ts @@ -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 }, @@ -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' } }, ]) }) diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index 4f351b49..daccf2ba 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -4,6 +4,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' export interface IpcRendererLike { @@ -189,6 +191,7 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra list: (): Promise => ipcRenderer.invoke('workspace:library:list') as Promise, read: (request: AssetLibraryReadRequest): Promise => ipcRenderer.invoke('workspace:library:read', request) as Promise, open: (request: AssetLibraryOpenRequest): Promise => ipcRenderer.invoke('workspace:library:open', request) as Promise, + thumbnail: (request: AssetLibraryThumbnailRequest): Promise => ipcRenderer.invoke('workspace:library:thumbnail', request) as Promise, }, }, diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index a6dbe4f3..e258ecb4 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -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, @@ -368,6 +371,8 @@ function AssetLibraryPopover({ searchQuery, sortMode, collapsedSectionKeys, + thumbnails, + panelWidth, onSelectEntry, onSearchQueryChange, onSortModeChange, @@ -375,6 +380,7 @@ function AssetLibraryPopover({ onOpenSelected, onRefresh, onClose, + onResizeMouseDown, }: { entries: ProjectedAssetLibraryEntry[] selectedEntryId: string | null @@ -384,6 +390,9 @@ function AssetLibraryPopover({ searchQuery: string sortMode: AssetLibrarySortMode collapsedSectionKeys: string[] + /** Data URLs keyed by workspacePath, populated lazily as thumbnails load. */ + thumbnails: Record + panelWidth: number onSelectEntry: (entryId: string) => void onSearchQueryChange: (value: string) => void onSortModeChange: (value: AssetLibrarySortMode) => void @@ -391,6 +400,7 @@ function AssetLibraryPopover({ 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)))) @@ -409,7 +419,8 @@ function AssetLibraryPopover({
@@ -425,6 +436,15 @@ function AssetLibraryPopover({
+ {/* Resize handle — drag to widen/narrow the library panel; size is persisted. */} +
+ ) })} @@ -575,8 +605,11 @@ export default function GeneratePage(): JSX.Element { const [librarySearchQuery, setLibrarySearchQuery] = useState('') const [librarySortMode, setLibrarySortMode] = useState('type') const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys()) + const [libraryThumbnails, setLibraryThumbnails] = useState>({}) + const [libraryPanelWidth, setLibraryPanelWidth] = useState(() => 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) @@ -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') @@ -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([]) @@ -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) { @@ -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 ( <>
@@ -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) @@ -939,6 +1008,7 @@ export default function GeneratePage(): JSX.Element { onOpenSelected={() => { void handleOpenSelectedLibraryEntry() }} onRefresh={() => { void loadLibraryEntries() }} onClose={() => setOpenPanel(null)} + onResizeMouseDown={onLibraryPanelResizeMouseDown} /> )}
diff --git a/src/areas/generate/assetLibraryService.test.ts b/src/areas/generate/assetLibraryService.test.ts index 9e7938c4..0ac08bce 100644 --- a/src/areas/generate/assetLibraryService.test.ts +++ b/src/areas/generate/assetLibraryService.test.ts @@ -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() @@ -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) +}) diff --git a/src/areas/generate/assetLibraryService.ts b/src/areas/generate/assetLibraryService.ts index 38738579..5c18d49d 100644 --- a/src/areas/generate/assetLibraryService.ts +++ b/src/areas/generate/assetLibraryService.ts @@ -5,6 +5,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from '../../shared/types/assetLibrary' import { projectAssetLibraryEntry, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' @@ -12,6 +14,7 @@ export interface AssetLibraryPreloadApi { list: () => Promise read: (request: AssetLibraryReadRequest) => Promise open: (request: AssetLibraryOpenRequest) => Promise + thumbnail: (request: AssetLibraryThumbnailRequest) => Promise } export type ProjectedAssetLibraryListResult = @@ -60,6 +63,12 @@ export function createAssetLibraryService(api: AssetLibraryPreloadApi) { if (!result.success) return result return { success: true, entry: projectAssetLibraryEntry(result.entry) } }, + // A missing/invalid thumbnail is never an error the UI needs to show — the + // Library list just falls back to its text-only row. + async thumbnail(request: AssetLibraryThumbnailRequest): Promise { + if (validateWorkspacePath(request.workspacePath)) return { success: false } + return api.thumbnail(request) + }, } } diff --git a/src/areas/generate/assetLibraryUi.test.ts b/src/areas/generate/assetLibraryUi.test.ts index e6b9fbca..681c292e 100644 --- a/src/areas/generate/assetLibraryUi.test.ts +++ b/src/areas/generate/assetLibraryUi.test.ts @@ -3,12 +3,15 @@ import test from 'node:test' import { buildAssetLibraryOpenRequest, + clampAssetLibraryPanelWidth, createAssetLibraryOpenJob, describeAssetLibraryOpenability, filterAssetLibraryScopeGroups, getDefaultAssetLibraryCollapsedSectionKeys, + getStoredAssetLibraryPanelWidth, isAssetLibraryEntryOpenable, resolveOpenPanelAfterLibrarySelection, + storeAssetLibraryPanelWidth, toggleAssetLibrarySectionKey, type AssetLibrarySortMode, type GenerateOpenPanel, @@ -123,3 +126,12 @@ test('builds linked-source open requests and import jobs for safe sidecars', () assert.equal(selection?.historyUrl, '/workspace/Workflows/run/hero.glb') assert.equal(selection?.job.outputUrl, '/workspace/Workflows/run/hero.glb') }) + +test('Library panel width clamps to sane bounds and degrades silently without a localStorage', () => { + assert.equal(clampAssetLibraryPanelWidth(0), 260) + assert.equal(clampAssetLibraryPanelWidth(999), 560) + assert.equal(clampAssetLibraryPanelWidth(400), 400) + // node --test has no `localStorage` global — both helpers must fall back rather than throw. + assert.equal(getStoredAssetLibraryPanelWidth(), 320) + assert.doesNotThrow(() => storeAssetLibraryPanelWidth(400)) +}) diff --git a/src/areas/generate/assetLibraryUi.ts b/src/areas/generate/assetLibraryUi.ts index 5e83e65a..295dd185 100644 --- a/src/areas/generate/assetLibraryUi.ts +++ b/src/areas/generate/assetLibraryUi.ts @@ -168,6 +168,35 @@ export function resolveOpenPanelAfterLibrarySelection(currentPanel: GenerateOpen return currentPanel === 'library' ? 'library' : currentPanel } +export const ASSET_LIBRARY_PANEL_MIN_WIDTH = 260 +export const ASSET_LIBRARY_PANEL_MAX_WIDTH = 560 +export const ASSET_LIBRARY_PANEL_DEFAULT_WIDTH = 320 + +const ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY = 'modly-library-panel-width' + +export function clampAssetLibraryPanelWidth(width: number): number { + return Math.min(ASSET_LIBRARY_PANEL_MAX_WIDTH, Math.max(ASSET_LIBRARY_PANEL_MIN_WIDTH, width)) +} + +/** Reads the user's saved Library panel width, falling back to the default when unset, invalid, or unreadable. */ +export function getStoredAssetLibraryPanelWidth(): number { + try { + const raw = Number(localStorage.getItem(ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY)) + return Number.isFinite(raw) && raw > 0 ? clampAssetLibraryPanelWidth(raw) : ASSET_LIBRARY_PANEL_DEFAULT_WIDTH + } catch { + return ASSET_LIBRARY_PANEL_DEFAULT_WIDTH + } +} + +/** Persists the Library panel width so it survives app restarts. */ +export function storeAssetLibraryPanelWidth(width: number): void { + try { + localStorage.setItem(ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY, String(width)) + } catch { + // Ignore quota/private-mode failures — the panel just won't remember its size. + } +} + function sortAssetLibraryEntries( entries: ProjectedAssetLibraryEntry[], sortMode: AssetLibrarySortMode, diff --git a/src/shared/types/assetLibrary.ts b/src/shared/types/assetLibrary.ts index fb5a602d..87c48785 100644 --- a/src/shared/types/assetLibrary.ts +++ b/src/shared/types/assetLibrary.ts @@ -81,3 +81,13 @@ export type AssetLibraryReadResult = export type AssetLibraryOpenResult = | { success: true, entry: AssetLibraryEntry } | { success: false, error: AssetLibraryError } + +export interface AssetLibraryThumbnailRequest { + workspacePath: string +} + +// Missing thumbnails are an expected, silent condition (not every asset has +// a rendered preview), so failure carries no error payload for the UI to show. +export type AssetLibraryThumbnailResult = + | { success: true, dataUrl: string } + | { success: false } diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index f631f6d5..e18ca4bd 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -7,6 +7,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from './assetLibrary' // ─── Extension types ────────────────────────────────────────────────────────── @@ -250,6 +252,7 @@ declare global { list: () => Promise read: (request: AssetLibraryReadRequest) => Promise open: (request: AssetLibraryOpenRequest) => Promise + thumbnail: (request: AssetLibraryThumbnailRequest) => Promise } } setup: {