Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
23 changes: 21 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@
const selectedImages = useImageStore((state) => state.selectedImages);
const selectedImage = useImageStore((state) => state.selectedImage);
const previewImage = useImageStore((state) => state.previewImage);
const clustersCount = useImageStore((state) => state.clusters.length);

Check warning on line 305 in App.tsx

View workflow job for this annotation

GitHub Actions / Test, lint and build

'clustersCount' is assigned a value but never used
const clusters = useImageStore((state) => state.clusters);
const clusterNavigationContext = useImageStore((state) => state.clusterNavigationContext);
const activeImageScope = useImageStore((state) => state.activeImageScope);
Expand Down Expand Up @@ -833,7 +833,7 @@
trialDaysRemaining,
canStartTrial,
isExpired,
isFree,

Check warning on line 836 in App.tsx

View workflow job for this annotation

GitHub Actions / Test, lint and build

'isFree' is assigned a value but never used
isPro,
canUseAnalytics,
canUseBatchExport,
Expand Down Expand Up @@ -4393,6 +4393,12 @@
onExitScope={
activeImageScope
? () => {
if (activeImageScope.type === 'prompt') {
setActiveImageScope(null);
resetLibraryGridScrollPosition();
setLibraryView('prompts');
return;
}
const dimension: ExploreDimension =
activeImageScope.type === 'cluster'
? 'clusters'
Expand All @@ -4408,7 +4414,9 @@
}
scopeReturnLabel={
activeImageScope
? activeImageScope.type === 'cluster'
? activeImageScope.type === 'prompt'
? 'Prompt Library'
: activeImageScope.type === 'cluster'
? 'Clusters'
: activeImageScope.type === 'model' || activeImageScope.type === 'managedModel'
? 'Models'
Expand Down Expand Up @@ -4548,7 +4556,18 @@
/>
)
) : libraryView === 'prompts' ? (
<PromptLibrary onViewSource={handleOpenFileFromDeepLink} />
<PromptLibrary onViewSource={handleOpenFileFromDeepLink} onFindImages={prompt => {
const query = prompt.trim();
handleClearAllFilters();
useImageStore.getState().clearFolderSelection();
useSemanticStore.getState().clearQuery();
useImageStore.getState().clearImageSelection();
useImageStore.getState().setPreviewImage(null);
setActiveImageScope({ type: 'prompt', id: query, label: query });
setCurrentPage(1);
resetLibraryGridScrollPosition();
setLibraryView('library');
}} />
) : libraryView === 'explore' ? (
<ExploreWorkspace
onNavigateToLibrary={() => {
Expand Down
54 changes: 54 additions & 0 deletions __tests__/PromptLibrary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,60 @@ async function editItem(name: string) {
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
}
describe('integrated Prompt Library', () => {
it('finds images from the card menu and opened details without changing the saved prompt', async () => {
const findImages = vi.fn();
render(<PromptLibrary onViewSource={vi.fn()} onFindImages={findImages} />);
const card = await screen.findByRole('button', { name: 'Open Studio portrait' });
fireEvent.contextMenu(card.closest('article')!);
fireEvent.click(screen.getByRole('menuitem', { name: 'Find images' }));
expect(findImages).toHaveBeenLastCalledWith('Studio portrait');
expect(screen.queryByRole('menu')).toBeNull();
fireEvent.click(card);
fireEvent.click(screen.getByRole('button', { name: 'Find images' }));
expect(findImages).toHaveBeenCalledTimes(2);
expect(findImages).toHaveBeenLastCalledWith('Studio portrait');
expect(mocks.mutate).not.toHaveBeenCalled();
});
it('opens templates to fill required values before finding images, including on Free', async () => {
mocks.advanced = false;
data.prompts[0].editor!.title = 'Template';
data.prompts[0].editor!.document = { ...makeDocument('Hello {{subject}}'), mode: 'template', variables: [{ name: 'subject', label: 'Subject', type: 'text', required: true, defaultValue: '', options: [] }] };
const findImages = vi.fn();
render(<PromptLibrary onViewSource={vi.fn()} onFindImages={findImages} />);
fireEvent.contextMenu((await screen.findByRole('button', { name: 'Open Template' })).closest('article')!);
fireEvent.click(screen.getByRole('menuitem', { name: 'Find images' }));
expect(findImages).not.toHaveBeenCalled();
expect(screen.getByRole('region', { name: 'Prompt details' })).toBeTruthy();
expect((screen.getByRole('button', { name: 'Find images' }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByLabelText('Subject *'), { target: { value: 'world' } });
fireEvent.click(screen.getByRole('button', { name: 'Find images' }));
expect(findImages).toHaveBeenLastCalledWith('Hello world');
expect(mocks.gate).not.toHaveBeenCalled();
expect(mocks.mutate).not.toHaveBeenCalled();
});
it('opens an optional-only template when its compiled search text is empty', async () => {
data.prompts[0].editor!.title = 'Optional template';
data.prompts[0].editor!.document = { ...makeDocument('{{subject}}'), mode: 'template', variables: [{ name: 'subject', label: 'Subject', type: 'text', required: false, defaultValue: '', options: [] }] };
const findImages = vi.fn();
render(<PromptLibrary onViewSource={vi.fn()} onFindImages={findImages} />);
fireEvent.contextMenu((await screen.findByRole('button', { name: 'Open Optional template' })).closest('article')!);
fireEvent.click(screen.getByRole('menuitem', { name: 'Find images' }));
expect(findImages).not.toHaveBeenCalled();
expect(screen.getByRole('region', { name: 'Prompt details' })).toBeTruthy();
expect((screen.getByRole('button', { name: 'Find images' }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'portrait' } });
fireEvent.click(screen.getByRole('button', { name: 'Find images' }));
expect(findImages).toHaveBeenCalledWith('portrait');
expect(mocks.mutate).not.toHaveBeenCalled();
});
it('finds images using negative text when the saved prompt has no positive text', async () => {
data.prompts[0].positivePrompt = '';
const findImages = vi.fn();
render(<PromptLibrary onViewSource={vi.fn()} onFindImages={findImages} />);
fireEvent.click(await screen.findByRole('button', { name: 'Open Studio portrait' }));
fireEvent.click(screen.getByRole('button', { name: 'Find images' }));
expect(findImages).toHaveBeenCalledWith('bad hands');
});
it('selects cards in list and grid without opening details or toggling twice', async () => {
render(<PromptLibrary onViewSource={vi.fn()} />);
await screen.findByRole('button', { name: 'Open Studio portrait' });
Expand Down
19 changes: 19 additions & 0 deletions __tests__/imageScope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ const collection: SmartCollection = {
const sources = { images, clusters: [cluster], collections: [collection] };

describe('resolveScopeImageIds', () => {
it('matches blocks after long positive and negative prompt prefixes without matching filenames', () => {
const prefix = 'portrait '.repeat(1024);
const positive = { ...alpha, prompt: prefix + 'Tail block' };
const negative = { ...beta, negativePrompt: prefix + 'Tail block' };
const filenameOnly = { ...gamma, name: 'Tail block.png', prompt: 'unrelated' };
const scope = { type: 'prompt' as const, id: 'TAIL\nblock', label: 'Tail block' };
const resolved = resolveScopeImageIds(scope, { ...sources, images: [positive, negative, filenameOnly] });
expect(resolved?.valid).toBe(true);
expect([...(resolved?.ids ?? [])]).toEqual([alpha.id, beta.id]);
});

it('preserves a prompt scope with no matches and resolves newly hydrated images', () => {
const scope = { type: 'prompt' as const, id: 'hydrated block', label: 'Block' };
expect(resolveScopeImageIds(scope, { ...sources, images: [] })).toEqual({ ids: new Set(), valid: true });
const hydrated = { ...alpha, prompt: 'hydrated block' };
expect(resolveScopeImageIds(scope, { ...sources, images: [hydrated] })?.ids.has(alpha.id)).toBe(true);
expect(resolveScopeImageIds({ ...scope, id: ' ' }, sources)?.ids.size).toBe(0);
});

it('returns null when there is no scope', () => {
expect(resolveScopeImageIds(null, sources)).toBeNull();
});
Expand Down
18 changes: 18 additions & 0 deletions __tests__/useImageStore.filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,24 @@ describe('useImageStore tri-state filters', () => {
expect(useImageStore.getState().filteredImages.map((image) => image.name)).toEqual(['searchable.png']);
});

it('keeps an exact long prompt searchable when its trailing term is outside the metadata budget', async () => {
const prompt = 'portrait '.repeat(1024) + 'unique-outside-index';
const image = createImage({ name: 'long-prompt.png', prompt });
useImageStore.getState().resetState();
useImageStore.setState({ directories: [directory], images: [image], filteredImages: [image], sortOrder: 'asc' });
useImageStore.getState().setSearchQuery(prompt);
await flushSearchWorker();
expect(useImageStore.getState().filteredImages).toEqual([]);
useImageStore.getState().setSearchQuery('');
await flushSearchWorker();
useImageStore.getState().setActiveImageScope({ type: 'prompt', id: prompt, label: prompt });
expect(useImageStore.getState().getScopedFilteredImages().map(entry => entry.id)).toEqual([image.id]);
// Standalone blocks also match beyond the indexed prefix.
useImageStore.getState().setActiveImageScope({ type: 'prompt', id: 'unique-outside-index', label: 'Block' });
expect(useImageStore.getState().getScopedFilteredImages().map(entry => entry.id)).toEqual([image.id]);
useImageStore.getState().setActiveImageScope(null);
expect(useImageStore.getState().getScopedFilteredImages().map(entry => entry.id)).toEqual([image.id]);
});
it('rebuilds memoized search text when an image is replaced by a new object', async () => {
// buildCatalogSearchText/buildCompactSearchText are memoized in WeakMaps keyed
// by the IndexedImage object itself. That is only sound because every store
Expand Down
4 changes: 3 additions & 1 deletion components/ActiveFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Calendar, CheckCircle, FolderOpen, Heart, Layers, Settings, X, type LucideIcon } from 'lucide-react';
import { Box, Calendar, CheckCircle, FolderOpen, Heart, Layers, Search, Settings, X, type LucideIcon } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useImageStore } from '../store/useImageStore';
import type { AdvancedFilters, ImageScope, NumericRangeFilter } from '../types';
Expand All @@ -10,13 +10,15 @@ const SCOPE_ICONS: Record<ImageScope['type'], LucideIcon> = {
managedModel: Box,
cluster: Layers,
collection: FolderOpen,
prompt: Search,
};

const SCOPE_LABELS: Record<ImageScope['type'], string> = {
model: 'Model',
managedModel: 'Managed model',
cluster: 'Cluster',
collection: 'Collection',
prompt: 'Prompt',
};

const formatRangeLabel = (label: string, range?: NumericRangeFilter, suffix = '') => {
Expand Down
14 changes: 11 additions & 3 deletions components/PromptLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function UnsavedDialog({ onSave, onDiscard, onCancel }: { onSave: () => void; on
const ref = usePromptDialogFocus(onCancel);
return <div className="fixed inset-0 z-[11000] flex items-center justify-center bg-black/70 p-4"><section ref={ref} role="dialog" aria-modal="true" aria-label="Unsaved changes" className="space-y-4 rounded-xl border border-gray-700 bg-gray-900 p-5"><h3 className="font-semibold">Save your changes before leaving?</h3><p className="text-xs text-gray-400">Your saved item has not been changed yet.</p><div className="flex justify-end gap-2"><button className={buttonClass} onClick={onCancel}>Cancel</button><button className={buttonClass} onClick={onDiscard}>Discard</button><button className={buttonClass + ' !border-accent/40 !bg-accent/15'} onClick={onSave}>Save</button></div></section></div>;
}
export default function PromptLibrary({ onViewSource }: { onViewSource: (path: string) => void | Promise<void> }) {
export default function PromptLibrary({ onViewSource, onFindImages }: { onViewSource: (path: string) => void | Promise<void>; onFindImages?: (prompt: string) => void }) {
const { prompts, blocks, loadLibrary, mutate, error, isLoading, selectedPromptId, select } = useSavedPromptStore();
const { canUseAdvancedPromptLibrary, showProModal } = useFeatureAccess();
const [kind, setKind] = useState<'prompt' | 'block'>(sessionDraft?.kind || 'prompt');
Expand Down Expand Up @@ -60,6 +60,14 @@ export default function PromptLibrary({ onViewSource }: { onViewSource: (path: s
setCopyStatus(response.success ? 'Copied.' : response.error || 'Copy failed.');
};
const startEdit = () => { if (active) { setEditing(active); setEditorKey(k => k + 1); } };
const findImages = (item: PromptLibraryItem) => {
const editor = normalizeEditor(item.editor);
const result = 'text' in item ? compilePrompt({ positivePrompt: item.text, editor: { ...editor, document: { ...makeDocument(item.text), mode: 'template', variables: editor.variables } } }) : compilePrompt(item);
if (result.errors.length) { open(item); setActionError('Fill the required values before finding images.'); return; }
const query = result.positivePrompt.trim() || result.negativePrompt.trim();
if (!query) { open(item); setActionError('Fill a value before finding images.'); return; }
onFindImages?.(query);
};
const selectId = (id: string) => setSelected(current => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const run = async (operation: Parameters<typeof mutate>[0]) => {
try { setActionError(''); const id = await mutate(operation); return { success: true as const, id }; }
Expand Down Expand Up @@ -110,13 +118,13 @@ export default function PromptLibrary({ onViewSource }: { onViewSource: (path: s
<div className="relative flex min-h-0 min-w-0 flex-1 gap-3">
{showFilters && !editing && <div className="absolute inset-y-0 left-0 z-10 w-60 bg-gray-950 lg:static lg:shrink-0"><PromptLibraryFiltersPanel all={all} kind={kind} filters={filters} onChange={setBrowseFilters} onClose={() => setShowFilters(false)} /></div>}
<div className={`min-h-0 min-w-0 flex-1 ${editing || active ? 'hidden lg:block' : ''} ${editing ? 'lg:!hidden' : ''}`}>
{isLoading && !all.length ? <p>Loading saved prompts…</p> : <PromptLibraryBrowser all={all} visible={visible} filters={filters} onFilters={setBrowseFilters} selected={selected} selectionMode={selectionMode} onSelectionMode={() => { setSelectionMode(!selectionMode); setSelected(new Set()); }} activeId={active?.id} contextNodeActions={false} onCopyItem={item => void copyItem(item)} onContextAction={(item, action) => {
{isLoading && !all.length ? <p>Loading saved prompts…</p> : <PromptLibraryBrowser all={all} visible={visible} filters={filters} onFilters={setBrowseFilters} selected={selected} selectionMode={selectionMode} onSelectionMode={() => { setSelectionMode(!selectionMode); setSelected(new Set()); }} activeId={active?.id} contextNodeActions={false} onFindImages={onFindImages ? findImages : undefined} onCopyItem={item => void copyItem(item)} onContextAction={(item, action) => {
if (action === 'edit') change(() => { select(item.id); setEditing(item); setEditorKey(key => key + 1); });
else if (action === 'duplicate') void run({ action: 'duplicate', kind, id: item.id }).then(result => { if (result.success) change(() => { setEditing(null); select(result.id || null); setShowDuplicates(false); dirty.current = false; }); });
else if (action === 'remove' && window.confirm('Delete this saved item?')) void run({ action: 'remove', kind, id: item.id }).then(result => { if (result.success && selectedPromptId === item.id) select(null); });
}} onOpen={open} onSelect={selectId} onFavorite={favorite} showFilters={showFilters} onToggleFilters={() => setShowFilters(!showFilters)} />}
</div>
{editing ? <div className="min-h-0 min-w-0 flex-1"><PromptEditor key={editorKey} initial={editing} kind={kind} onDirty={onDirty} onViewSource={onViewSource} requestSave={requestSave} onSaved={afterSaved} onSaveFailed={() => { saveIntent.current = null; setPending(null); setRequestSave(0); }} onClose={() => change(() => { setEditing(null); dirty.current = false; })} /></div> : active && <div className="flex min-h-0 w-full min-w-0 flex-col gap-2 lg:w-[42%] lg:min-w-[340px] lg:max-w-xl"><PromptDetails key={active.id} item={active} onEdit={startEdit} onClose={() => { select(null); setShowDuplicates(false); }} onFavorite={() => favorite(active)} onDuplicate={duplicate} onRemove={remove} onDuplicates={() => setShowDuplicates(!showDuplicates)} onViewSource={onViewSource} />{showDuplicates && <PromptDuplicatesPanel item={active} items={all} onOpen={open} />}</div>}
{editing ? <div className="min-h-0 min-w-0 flex-1"><PromptEditor key={editorKey} initial={editing} kind={kind} onDirty={onDirty} onViewSource={onViewSource} requestSave={requestSave} onSaved={afterSaved} onSaveFailed={() => { saveIntent.current = null; setPending(null); setRequestSave(0); }} onClose={() => change(() => { setEditing(null); dirty.current = false; })} /></div> : active && <div className="flex min-h-0 w-full min-w-0 flex-col gap-2 lg:w-[42%] lg:min-w-[340px] lg:max-w-xl"><PromptDetails key={active.id} item={active} onEdit={startEdit} onClose={() => { select(null); setShowDuplicates(false); }} onFavorite={() => favorite(active)} onDuplicate={duplicate} onRemove={remove} onDuplicates={() => setShowDuplicates(!showDuplicates)} onFindImages={onFindImages} onViewSource={onViewSource} />{showDuplicates && <PromptDuplicatesPanel item={active} items={all} onOpen={open} />}</div>}
</div>
{pending && <UnsavedDialog onSave={() => { saveIntent.current = pending; setRequestSave(n => n + 1); }} onDiscard={() => { const action = pending; setPending(null); saveIntent.current = null; clearDraft(); dirty.current = false; action(); }} onCancel={() => { setPending(null); saveIntent.current = null; }} />}
{showTransfer && <PromptImportExportDialog snapshot={exportSnapshot} librarySnapshot={{ prompts, blocks }} filteredSnapshot={selected.size ? filteredSnapshot : undefined} scope={selected.size ? 'Selected items' : visible.length !== all.length ? 'Filtered items' : 'All items in this view'} onClose={() => setShowTransfer(false)} />}
Expand Down
Loading
Loading