Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
15 changes: 14 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import { ModelLibraryPicker } from './components/ModelManagerPanels';
import { startModelManager, setModelLibraryOpener, setModelImageOpener, useModelManager, dismissModelNotification, showModelUpdates } from './services/modelLibrary/manager';
import PromptLibrary from './components/PromptLibrary';
import { boundImageSearchQuery } from './utils/imageSearchQuery';
import GridToolbar from './components/GridToolbar';
import AnalyticsSummaryStrip from './components/AnalyticsSummaryStrip';
import BatchExportModal from './components/BatchExportModal';
Expand Down Expand Up @@ -302,7 +303,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 306 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 +834,7 @@
trialDaysRemaining,
canStartTrial,
isExpired,
isFree,

Check warning on line 837 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 @@ -4548,7 +4549,19 @@
/>
)
) : libraryView === 'prompts' ? (
<PromptLibrary onViewSource={handleOpenFileFromDeepLink} />
<PromptLibrary onViewSource={handleOpenFileFromDeepLink} onFindImages={prompt => {
const query = boundImageSearchQuery(prompt);
Comment thread
LuqP2 marked this conversation as resolved.
Outdated
handleClearAllFilters();
useImageStore.getState().clearFolderSelection();
useSemanticStore.getState().clearQuery();
useImageStore.getState().clearImageSelection();
useImageStore.getState().setPreviewImage(null);
setSearchInputValue(query);
setSearchQuery(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
16 changes: 16 additions & 0 deletions __tests__/useImageStore.filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Directory, IndexedImage } from '../types';
import { useImageStore } from '../store/useImageStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { boundImageSearchQuery, MAX_SEARCH_TEXT_LENGTH } from '../utils/imageSearchQuery';

const directory: Directory = {
id: 'dir-1',
Expand Down Expand Up @@ -378,6 +379,21 @@ 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([]);
const query = boundImageSearchQuery(prompt);
expect(query.length).toBeLessThanOrEqual(MAX_SEARCH_TEXT_LENGTH);
expect(query).not.toContain('unique-outside-index');
useImageStore.getState().setSearchQuery(query);
await flushSearchWorker();
expect(useImageStore.getState().filteredImages.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
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
6 changes: 4 additions & 2 deletions components/promptLibrary/PromptDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { getPreviewValues, rememberPreviewValues } from '../../services/promptLibrary/sessionDraft';
import { Heart, MoreHorizontal, Pencil, X, ChevronLeft, ChevronRight } from 'lucide-react';
import { Heart, MoreHorizontal, Pencil, X, ChevronLeft, ChevronRight, Search } from 'lucide-react';
import type { PromptLibraryItem } from '../../types';
import { allVariables, compilePrompt, makeDocument, normalizeEditor } from '../../services/promptLibrary/core.mjs';
import { usePromptLibraryPreview } from '../../hooks/usePromptLibraryPreview';
Expand All @@ -10,11 +10,12 @@ import { VariableValues, buttonClass } from './PromptVariables';
import PromptTagsInput from './PromptTagsInput';
import { useSavedPromptStore } from '../../store/useSavedPromptStore';

export default function PromptDetails({ item, onEdit, onClose, onFavorite, onDuplicate, onRemove, onDuplicates, onViewSource, renderActions, renderSecondaryActions, workspace = false, onSendSelection, onPrevious, onNext, position }: {
export default function PromptDetails({ item, onEdit, onClose, onFavorite, onDuplicate, onRemove, onDuplicates, onViewSource, onFindImages, renderActions, renderSecondaryActions, workspace = false, onSendSelection, onPrevious, onNext, position }: {
item: PromptLibraryItem; onEdit: () => void; onClose: () => void; onFavorite: () => void;
onDuplicate: () => void; onRemove: () => void; onDuplicates: () => void; onViewSource: (path: string) => void | Promise<void>;
renderActions?: (result: ReturnType<typeof compilePrompt>) => React.ReactNode;
renderSecondaryActions?: () => React.ReactNode;
onFindImages?: (prompt: string) => void;
onPrevious?: () => void;
onNext?: () => void;
position?: string;
Expand Down Expand Up @@ -72,6 +73,7 @@ export default function PromptDetails({ item, onEdit, onClose, onFavorite, onDup
{variables.length > 0 && <section className="space-y-2 rounded-lg border border-accent/25 bg-accent/5 p-3"><h4 className="text-sm font-semibold">Fill values</h4><p className="text-xs text-gray-400">These values replace the placeholders in the final text when copying or sending to a node. Your saved text stays unchanged.</p><VariableValues variables={variables} values={values} onChange={setValues} /></section>}
<div onContextMenu={e => { const text = selectedPromptText(); if (text) { e.preventDefault(); setSelection({ text, x: Math.min(e.clientX, window.innerWidth - 240), y: Math.min(e.clientY, window.innerHeight - (onSendSelection ? 150 : 100)) }); } }}><PromptPreview compact={workspace} channel={workspace && block ? 'block' : undefined} result={result} heading={block ? 'Block text' : 'Prompt'} /></div>
{!workspace && renderActions?.(result)}
{onFindImages && <button className={buttonClass} disabled={result.errors.length > 0 || !(result.positivePrompt.trim() || result.negativePrompt.trim())} onClick={() => onFindImages(result.positivePrompt.trim() || result.negativePrompt.trim())}><Search size={14} />Find images</button>}
<div className="space-y-1"><PromptTagsInput compact={workspace} tags={editor.tags} suggestions={suggestions} disabled={savingTags} onChange={tags => void saveTags(tags)} />{(savingTags || tagStatus) && <p role="status" className="text-[11px] text-gray-500">{savingTags ? 'Saving tags…' : tagStatus}</p>}</div>
{renderSecondaryActions?.()}
{editor.category && <p className="text-xs text-gray-400">Category: {editor.category}</p>}
Expand Down
Loading
Loading