Merge remote-tracking branch 'origin/main' into fix/512-delete-review-polish

This commit is contained in:
Vito Sansevero
2026-10-29 21:37:28 -07:00
25 changed files with 1085 additions and 50 deletions
+3 -2
View File
@@ -129,8 +129,9 @@ export const KINDS = Object.freeze({
scan: Object.freeze({
...GENERIC,
pillVerb: 'Scanning',
startToast: () => 'Scan started.',
finishToast: namedFinish(() => 'Scan', addedOf, scanLine),
// #498: "Return images to the library" is a scan of the returned files; its toasts carry its own title.
startToast: job => (job.scope?.files === 'returned' ? `${job.title} started.` : 'Scan started.'),
finishToast: namedFinish(job => (job.scope?.files === 'returned' ? job.title : 'Scan'), addedOf, scanLine),
line: scanLine,
kept: addedOf,
tiles: tiles([['added', 'Added to Library', 'added'], ['known', 'Already known', ''],
+8 -1
View File
@@ -234,7 +234,7 @@ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
* collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes.
*/
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback,
collections = null, onCollectionsChanged = null, onDelete = null}) {
collections = null, onCollectionsChanged = null, onDelete = null, onReturn = null}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -342,6 +342,13 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
// #480: Delete… for a listed Images file only, last in the actions.
const removable = onDelete ? revealTarget(record.identity) : null;
if (removable) footer.append(deleteButton(removable));
// #498 (D162): a removed file (Show removed images) offers Return to library in Delete…'s place.
const returnable = onReturn ? revealTarget(record.identity) : null;
if (returnable) {
const button = createButton('Return to library');
contentScope.listen(button, 'click', () => onReturn(returnable, button));
footer.append(button);
}
if (!full && footer.childNodes.length) container.append(footer);
}
/**
+5 -3
View File
@@ -53,7 +53,7 @@ export function cardMedia(image, lazy = false) {
* cannot be served, and `showInfo: false` hides the title and file line (D97). A `linked_only` image (LoRA media in
* LoraManager's folders, #355) has no Tag action.
*/
export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true } = {}) {
export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true, removed = false } = {}) {
const card = el('article', undefined, 'media-card');
card.dataset.imageKey = imageKey(image); card.setAttribute('role', 'listitem');
card.style.setProperty('--media-ratio', mediaRatio(image));
@@ -71,7 +71,7 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m
label.append(box);
overlays.append(label);
}
if (typeof image.favorite === 'boolean') {
if (typeof image.favorite === 'boolean' && !removed) {
const star = createButton('', 'star-button');
star.setAttribute('aria-label', `Favorite ${image.filename}`);
paintStar(star, image.favorite, favoriteBusy);
@@ -82,6 +82,8 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m
const text = el('div');
const title = el('h3', cardTitle(image)); if (image.path) title.title = image.path;
text.append(title, el('p', cardSubtitle(image)));
// #498 (D162): a card in Show removed images carries the concept's `.chip`.
if (removed) text.append(el('span', 'Removed', 'chip'));
// D97: `showInfo` false (System › Appearance) hides the title and file line; Info and Tag stay.
if (!showInfo) text.hidden = true;
const actions = el('div', undefined, 'image-card-actions');
@@ -90,7 +92,7 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
actions.append(inspectButton);
// #355: LoRA-linked media lives in LoraManager's folders; tags are kept for output files only.
if (image.linked_only !== true) actions.append(createTagButton(image));
if (image.linked_only !== true && !removed) actions.append(createTagButton(image));
info.append(text, actions);
card.append(art, info);
return card;
+97
View File
@@ -0,0 +1,97 @@
/**
* Images › Show removed images › Return to library (#498, D162).
*
* A removed image is a file "Remove from the library only" (#480) hid: it stays on disk without records. Return to
* library posts the checked images (or "all N matching" in the removed view) to `POST /images/removed/return`, which
* starts a scan job, "Return images to the library": the marks go, then those files are scanned, so their prompt
* links come back from their metadata. Tags, categories, collections and favorites do not come back. Nothing is
* deleted or moved, so there is no review (as the Library's Restore, D152).
*/
import { followJobEnd } from './images-tagging-bulk.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { countOf } from './plural.mjs';
export const RETURN_URL = '/prompt_manager/images/removed/return';
export const MAX_RETURN = 500;
export const TOO_MANY = 'Select at most 500 images.';
export const RETURN_LABEL = 'Return to library';
export const SHOW_REMOVED = 'Show removed images';
export const NOT_BACK = 'Tags, categories, collections and favorites do not come back.';
export const RETURN_FAILED = 'The images could not be returned. Nothing was changed.';
export const NONE_REMOVED = 'None of the selected images are removed from the library.';
export const SOURCE_CHANGED = 'The sources changed. Refresh the images and try again.';
/** The heading toggle's text: "Show removed images (N)", without the count while unknown. */
export function toggleLabel(total) {
return Number.isSafeInteger(total) && total >= 0 ? `${SHOW_REMOVED} (${total.toLocaleString('en-US')})` : SHOW_REMOVED;
}
/** The removed view's caption: "N removed images · files on disk, hidden from Images". */
export function removedCaption(total) {
return `${countOf(total, 'removed image')} · files on disk, hidden from Images`;
}
/** The removed view's empty text. */
export function removedEmpty(filtered) {
return filtered ? 'No removed images match.' : 'No removed images.';
}
export function startToast(count) {
return `Returning ${countOf(count, 'image')} to the library…`;
}
/** The toast when the return job ends: how many came back and how many the scan linked. */
export function finishedToast(job, returned) {
if (job?.state === 'failed') return `Returning images stopped: ${job.status_line || 'see Activity'}.`;
if (job?.state === 'cancelled') return 'Returning images was cancelled. The images already returned stay in the library.';
const linked = Number(job?.counters?.added ?? 0) || 0;
const back = `Returned ${countOf(returned, 'image')} to the library`;
const links = linked ? `; ${countOf(linked, 'image')} linked to ${linked === 1 ? 'its prompt' : 'their prompts'}` : '';
return `${back}${links}. ${NOT_BACK}`;
}
export function returnFailure(error) {
if (error?.code === 'no-images') return NONE_REMOVED;
if (error?.code === 'too-many-images') return TOO_MANY;
if (error?.code === 'source-changed') return SOURCE_CHANGED;
if (error?.code === 'busy' && error.message) return error.message;
return RETURN_FAILED;
}
/**
* Return `images` (the checked ones, or the inspector's one) or `filter` ("all N matching" in the removed view, `count`
* of them). `trigger` is aria-disabled until the server answers; a second press is ignored. `onReturned(job)` runs
* when the job ends (at once without an Activity store).
*/
export async function returnImages({api, scope, store, images = [], filter = null, count = images.length, trigger = null,
onReturned = () => {}, notify = showWorkspaceToast}) {
if (count > MAX_RETURN) { notify(TOO_MANY); return null; }
if (!filter && !images.length) return null;
if (trigger?.getAttribute('aria-disabled') === 'true') return null;
trigger?.setAttribute('aria-disabled', 'true');
let answer;
try {
const body = filter ? {filter: {...filter, removed: '1'}}
: {items: images.map(({root_index, root_scope, path}) => ({root_index, root_scope, path}))};
answer = await api.post(RETURN_URL, body, {signal: scope.signal});
} catch (error) {
if (!scope.signal.aborted) notify(returnFailure(error));
return null;
} finally {
trigger?.removeAttribute('aria-disabled');
}
if (scope.signal.aborted) return null;
const returned = Number(answer?.returned ?? count) || 0;
notify(startToast(returned));
let job;
try {
job = store ? store.track(answer.job) : answer.job;
} catch {
store?.load?.();
onReturned(null);
return null;
}
if (store) followJobEnd({store, scope, id: job.id, onEnd: ended => { notify(finishedToast(ended, returned)); onReturned(ended); }});
else onReturned(job);
return job;
}
+21 -1
View File
@@ -51,7 +51,8 @@ function el(tag, text, className) {
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null }) {
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null, removedView = null,
onReturn = null }) {
let checked = initial;
let exporting = false, categorizing = false;
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
@@ -103,10 +104,29 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
bulk.append(deleteButton);
}
bulk.append(clearButton);
// #498 (D162): in Show removed images the bar holds only Return to library and Clear; every other action writes
// records, which would show a removed file again without reading it.
const normalActions = [...bulk.children].filter(node => node !== count && node !== clearButton);
const returnButton = onReturn ? createButton('Return to library') : null;
if (returnButton) {
scope.listen(returnButton, 'click', () => {
const target = matchingTarget();
onReturn(checkedImages(), target, returnButton);
});
}
let shownRemoved = false;
function paintMode() {
const removed = Boolean(returnButton && removedView?.());
if (removed === shownRemoved) return;
shownRemoved = removed;
for (const node of [...normalActions, returnButton]) node?.remove();
clearButton.before(...(removed ? [returnButton] : normalActions));
}
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
if (!checked.size && !allMatching) { touchMode = false; bulk.remove(); return; }
paintMode();
const listing = getListing();
const label = allMatching ? matchingLabel(listing.total) : selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
+24 -7
View File
@@ -62,6 +62,14 @@ function queryFor(filters, batchSize) {
const media = filters.media ?? 'all';
if (!['all', 'image', 'video'].includes(media)) throw new TypeError('Invalid media filter');
if (media !== 'all') query.set('media', media);
// #498 (D162): Show removed images lists only the files a removal mark hides; never with a category, tags,
// favorites or a collection (the server refuses them: such a file has no records).
const removed = filters.removed ?? false;
if (typeof removed !== 'boolean') throw new TypeError('Invalid removed filter');
if (removed && (filters.category || (filters.tags ?? []).length || filters.favorites || (filters.collection ?? '') !== '')) {
throw new TypeError('Invalid removed filter');
}
if (removed) query.set('removed', '1');
const sort = filters.sort ?? 'newest';
if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter');
query.set('sort', sort);
@@ -107,6 +115,9 @@ function validatePage(page, cursor) {
}
}
/** A page's `removed_total` (#498), or null from an older server. */
const removedOf = page => (isCount(page?.removed_total) ? page.removed_total : null);
/** `state` with the item named by `key` patched (both frozen); the same state when no item has that key. */
export function withItem(state, key, patch) {
if (!patch || typeof patch !== 'object' || 'path' in patch || 'root_index' in patch) {
@@ -128,8 +139,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
throw new TypeError('Invalid image batch size');
}
// positiveCount: distinct linked positives over the whole matching set, or null while unknown (PR B contract).
// removedTotal (#498): the removed images in the selected source(s), from the first page; null while unknown.
let state = Object.freeze({ items: Object.freeze([]), total: 0, cursor: null,
hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null });
hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null, removedTotal: null });
let query = queryFor({}, batchSize);
let controller = null;
let disposed = false;
@@ -149,7 +161,8 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
const items = Object.isFrozen(saved.items) && saved.items.every(Object.isFrozen) ? saved.items :
Object.freeze(saved.items.map(item => Object.freeze({ ...item })));
state = Object.freeze({ items, total: saved.total, cursor: saved.cursor, hasMore: saved.hasMore,
loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null });
loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null,
removedTotal: isCount(saved.removedTotal) ? saved.removedTotal : null });
}
function publish(update) {
@@ -188,8 +201,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
// first page's count, and a first page without one leaves it unknown.
const positiveCount = isCount(page.positive_count) ? page.positive_count : cursor === null ? null : state.positiveCount;
controller = null;
const removedTotal = cursor === null ? removedOf(page) : state.removedTotal;
publish({ items, total: page.total, cursor: page.next_cursor, hasMore: page.has_more,
loading: false, error: null, loaded: items.length, positiveCount });
loading: false, error: null, loaded: items.length, positiveCount, removedTotal });
return incoming.length;
} catch (error) {
if (!disposed && request === controller) {
@@ -224,7 +238,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
const request = refreshing = new AbortController();
const target = Math.max(state.loaded, 1);
const items = [], keys = new Set();
let cursor = null, total = 0, hasMore = true, positiveCount = null;
let cursor = null, total = 0, hasMore = true, positiveCount = null, removedTotal = null;
try {
while (items.length < target && hasMore) {
const parameters = new URLSearchParams(query);
@@ -239,7 +253,10 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
keys.add(key);
items.push(Object.freeze({ ...item }));
}
if (cursor === null) positiveCount = isCount(page.positive_count) ? page.positive_count : null;
if (cursor === null) {
positiveCount = isCount(page.positive_count) ? page.positive_count : null;
removedTotal = removedOf(page);
}
({total, next_cursor: cursor, has_more: hasMore} = page);
}
} catch (error) {
@@ -251,7 +268,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
refreshing = null;
seen = keys;
state = Object.freeze({ ...state, items: Object.freeze(items), total, cursor, hasMore, loading: false, error: null,
loaded: items.length, positiveCount });
loaded: items.length, positiveCount, removedTotal });
onChange(state, {replaced: true});
return items.length;
}
@@ -267,7 +284,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
query = nextQuery;
seen = new Set();
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
loading: false, error: null, loaded: 0, positiveCount: null });
loading: false, error: null, loaded: 0, positiveCount: null, removedTotal: state.removedTotal });
return load ? loadMore() : 0;
}
+59 -8
View File
@@ -10,6 +10,7 @@ import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { openDeleteImages } from './images-delete.mjs';
import { removedCaption, removedEmpty, returnImages, toggleLabel } from './images-return.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
@@ -177,6 +178,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// A collection and favorites are never both the filter (DC10).
favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection };
filters.tags = [...filters.tags];
// #498 (D162): Show removed images belongs to this visit only: never restored, saved or handed on.
filters.removed = false;
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
@@ -186,20 +189,28 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
if (!embedded && !filters.favorites && !filters.collection && !saved && !filters.removed) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
scope.cleanup(() => main.classList.remove('images-main'));
// The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt.
const heading = embedded ? null : pageHeading(main);
let importButton = null, importDialog = null;
let importButton = null, importDialog = null, showRemoved = null;
if (heading) {
heading.description.textContent = 'Your generations, their prompts, and the next idea.';
importButton = createButton('');
importButton.append(createWorkspaceIcon('upload'), el('span', 'Import'));
heading.actions.prepend(importButton);
scope.cleanup(() => importButton.remove());
// #498 (D162): Show removed images (N), before Import, only on the Images page and only while there are any.
if (imagesPage) {
// Attached only while shown, so a library without removals keeps the concept's heading exactly.
showRemoved = createButton(toggleLabel(null));
showRemoved.setAttribute('aria-pressed', 'false');
scope.cleanup(() => showRemoved.remove());
scope.listen(showRemoved, 'click', () => setRemovedView(!filters.removed));
}
scope.listen(importButton, 'click', () => {
importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning,
onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan,
@@ -301,6 +312,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
onDeleted:deleted,
// #498: the removed view's bar holds only Return to library and Clear.
removedView:() => filters.removed,
onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger}),
// The Set category field suggests the toolbar's categories (#332 MC13).
getCategories:() => categoryNames,
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
@@ -339,10 +353,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
selection = imageKey(image);
selectedImage = image;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
// #498: a removed file's panel offers Return to library and nothing that writes (Collections…, Delete…, tag chips).
const removedView = filters.removed;
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy,
onDelete:embedded ? null : (target, button) => deleteOne(target, button)});
onTagFilter:embedded || removedView ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
collections:removedView ? null : inspectorCollections, onCollectionsChanged:collectionsChangedBy,
onDelete:embedded || removedView ? null : (target, button) => deleteOne(target, button),
onReturn:removedView ? (target, button) => returnSome({images:[target], trigger:button}) : null});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
@@ -360,7 +377,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const key = imageKey(image);
const card = createImageCard(image, {checked:bar ? bar.isChecked(key) : null, favoriteBusy:favoriteBusy(key),
// D97: "Show image details on cards" off hides the title and file line; Info and Tag stay.
showInfo:preferences?.showImageInfo !== false});
showInfo:preferences?.showImageInfo !== false,
// #498: a removed file has no records; its star and Tag would write and show it again.
removed:filters.removed});
markSelected(card, key === selection);
return card;
}
@@ -378,6 +397,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
if (!image) return;
const action = event.target.closest('.image-card-actions button');
// #498: in the removed view a card only inspects (the viewer's writes would show the file again).
if (filters.removed) { if (event.target.closest('.image-open, .image-card-actions button')) inspect(image); return; }
if (event.target.closest('.star-button')) toggleFavorite(api, image);
else if (event.target.closest('.image-open')) previewImage(image);
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
@@ -553,7 +574,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
state = next;
bar?.listingChanged();
if (!embedded) {
setText(count, countLine(next));
setText(count, filters.removed ? removedCaption(next.total) : countLine(next));
paintRemovedToggle(next);
setText(captionDay, captionDate(next.items, filters.sort));
if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent;
}
@@ -576,7 +598,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); });
}
if (showsEmpty) {
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES);
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText
: filters.removed ? removedEmpty(Boolean(filters.q || filters.subfolder || filters.media !== 'all')) : NO_MATCHES);
empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0);
}
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
@@ -656,6 +679,32 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
loadCategories();
reset();
}
/** #498 (D162): enter or leave Show removed images; entering clears the Category and Tags it never combines with. */
function setRemovedView(on) {
if (filters.removed === on) return;
filters.removed = on;
if (on && (filters.category || filters.tags.length)) {
filters.category = ''; filters.tags = [];
toolbar?.setValue({q:filters.q, category:'', sort:filters.sort});
toolbar?.setActiveFilters(activeRefinements(filters));
}
reset();
}
function paintRemovedToggle(next) {
if (!showRemoved) return;
const total = next.removedTotal;
// A return that leaves nothing removed ends the view: Images lists every file again.
if (filters.removed && total === 0 && !next.loading) { queueMicrotask(() => setRemovedView(false)); }
setText(showRemoved, toggleLabel(total));
setAttr(showRemoved, 'aria-pressed', String(filters.removed));
const shown = filters.removed || total > 0;
if (!shown) showRemoved.remove();
else if (!showRemoved.isConnected) heading.actions.prepend(showRemoved);
}
/** #498: Return to library for the checked images, "all N matching" (`filter`) or the inspector's one. */
function returnSome({images = [], filter = null, count = images.length, trigger = null}) {
return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) reset(); }});
}
/** #480: Delete… for one file, from the inspector or the viewer (`after` closes the viewer first). */
function deleteOne(target, trigger, after = null) {
openDeleteImages({api, scope, store:activity, images:[target], trigger, onDeleted:() => { after?.(); deleted(); }});
@@ -673,6 +722,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (savedScopePending && !discoverySettled) { updateScope(); return; }
settleSavedScope();
updateScope();
// #498: a removed file has no category or tags, so choosing one (toolbar, Refine, a tag chip) ends the view.
if (filters.removed && (filters.category || filters.tags.length)) filters.removed = false;
stream.reset(filters, {load:!recovering});
}
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
+5 -2
View File
@@ -129,9 +129,12 @@ export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
/** The count a review decision or a finished metadata scan can change. */
export const REVIEW_WRITE_COUNTS = Object.freeze(['review']);
/** Job kind -> the counts re-read once when such a job ends (a scan feeds Needs review; #501: a delete hides images). */
/**
* Job kind -> the counts re-read once when such a job ends: a scan feeds Needs review and can add prompts, and since
* #498 "Return images to the library" (a scan) shows removed images again; #501: a delete hides images.
*/
export const JOB_END_COUNTS = Object.freeze({
scan: REVIEW_WRITE_COUNTS,
scan: Object.freeze(['review', 'library', 'images']),
image_delete: Object.freeze(['images', 'favorites', 'review']),
});
// Trailing delay that coalesces quick star toggles into one Favorites count read (#249 M1).