feat(images): Show removed images and Return to library (#498)

- GET /images/output?mode=workspace&removed=1 lists only files a removal
  mark hides; every workspace page carries removed_total.
- POST /images/removed/return {items | filter} starts a scan job,
  "Return images to the library": the marks that still hide their file
  go in one transaction, then exactly those files are scanned, so their
  prompt links come back from their metadata. Tags, categories,
  collections and favorites do not come back.
- The marks generation moves again after the commit, so a reader that
  cached the marks mid-transaction reloads them.
- Images: Show removed images (N) heading toggle (D162), Removed chips,
  a bar with only Return to library and Clear, Return to library in the
  side panel; the view is never saved or restored.
This commit is contained in:
Vito Sansevero
2026-10-29 14:11:51 -07:00
parent 245d0cdc6a
commit ed9574febd
21 changed files with 1030 additions and 45 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 = () => false,
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;
}
+57 -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(); }});
+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).