fix(images): guard source recovery and wrap viewer actions
Clear stale root and folder intent, cancel old pages, and wait for current source discovery before loading a refreshed collection. Preserve safe retry through failed, superseded, and disposed discovery. Wrap narrow viewer footer actions and wait for measured gallery viewport settling in responsive checks. Refs #216; addresses PR #224 review.
This commit is contained in:
@@ -114,7 +114,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(filters = {}) {
|
||||
async function reset(filters = {}, {load = true} = {}) {
|
||||
if (disposed) return 0;
|
||||
const nextQuery = queryFor(filters, batchSize);
|
||||
controller?.abort();
|
||||
@@ -123,7 +123,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
seen = new Set();
|
||||
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
|
||||
loading: false, error: null, loaded: 0 });
|
||||
return loadMore();
|
||||
return load ? loadMore() : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+33
-19
@@ -37,7 +37,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
let density = visit?.density || 'comfortable';
|
||||
let metrics = workspaceImageMetrics(window.innerWidth, density);
|
||||
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest;
|
||||
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, recovering = false;
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
|
||||
@@ -93,14 +93,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
try {
|
||||
await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
|
||||
if (scope.signal.aborted) return;
|
||||
await loadOptions();
|
||||
if (!scope.signal.aborted) reset();
|
||||
if (await loadOptions() && !scope.signal.aborted) reset();
|
||||
} catch {
|
||||
if (scope.signal.aborted) return;
|
||||
optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.';
|
||||
optionsStatus.hidden = false;
|
||||
} finally {
|
||||
refresh.disabled = false;
|
||||
refresh.disabled = recovering;
|
||||
}
|
||||
});
|
||||
const currentGallery = el('a', 'Current gallery', 'workspace-link');
|
||||
@@ -325,12 +324,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
state = next;
|
||||
count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} linked images` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`;
|
||||
if (changed) updateLayout();
|
||||
message.textContent = next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
|
||||
message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
|
||||
if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status');
|
||||
retry.hidden = !next.error;
|
||||
more.hidden = !next.hasMore || !!next.error;
|
||||
more.setAttribute('aria-disabled', String(next.loading));
|
||||
end.textContent = !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : '';
|
||||
more.setAttribute('aria-disabled', String(next.loading || recovering));
|
||||
end.textContent = !recovering && !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : '';
|
||||
if (!selection && next.items.length) inspect(next.items[0]);
|
||||
scheduleLoad();
|
||||
}
|
||||
@@ -339,10 +338,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
stream.dispose();
|
||||
for (const video of body.querySelectorAll('video')) releaseVideo(video);
|
||||
});
|
||||
scope.listen(more, 'click', () => stream.loadMore());
|
||||
scope.listen(retry, 'click', () => stream.loadMore());
|
||||
scope.listen(more, 'click', () => { if (!recovering) stream.loadMore(); });
|
||||
scope.listen(retry, 'click', () => { if (!recovering) stream.loadMore(); });
|
||||
function maybeLoad() {
|
||||
if (!observer || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return;
|
||||
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return;
|
||||
const bounds = continuation.getBoundingClientRect();
|
||||
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
|
||||
}
|
||||
@@ -358,17 +357,29 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
function reset() {
|
||||
mediaViewer.stopSlideshow();
|
||||
main.querySelector('.image-preview[open]')?.close();
|
||||
inspection.clear();
|
||||
detailInspector?.dispose(); detailInspector = null;
|
||||
if (!recovering) {
|
||||
inspection.clear();
|
||||
detailInspector?.dispose(); detailInspector = null;
|
||||
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
|
||||
}
|
||||
selection = null;
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
|
||||
stream.reset(filters);
|
||||
stream.reset(filters, {load:!recovering});
|
||||
}
|
||||
async function refreshInspectionCollection() {
|
||||
if (scope.signal.aborted) return;
|
||||
// Numeric roots may now name another source; retire the old stream first.
|
||||
recovering = true;
|
||||
filters.root = 'all';filters.subfolder = '';filters.q = search.value.trim();
|
||||
source.replaceChildren(new Option('All sources', 'all'));folder.replaceChildren(new Option('All folders', ''));
|
||||
for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = true;
|
||||
optionsStatus.hidden = true;
|
||||
reset();
|
||||
const discovery = loadOptions(), request = optionsRequest;
|
||||
if (!await discovery || scope.signal.aborted || request !== optionsRequest) return;
|
||||
recovering = false;
|
||||
for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = false;
|
||||
reset();
|
||||
await loadOptions();
|
||||
}
|
||||
async function loadOptions() {
|
||||
optionsRequest?.abort();
|
||||
@@ -377,22 +388,25 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'});
|
||||
if (filters.root !== 'all') params.set('root', filters.root);
|
||||
const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal});
|
||||
if (scope.signal.aborted || request !== optionsRequest) return;
|
||||
if (scope.signal.aborted || request !== optionsRequest) return false;
|
||||
source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index))));
|
||||
source.value = filters.root;
|
||||
folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path)));
|
||||
folder.value = filters.subfolder;
|
||||
optionsStatus.hidden = true;
|
||||
return true;
|
||||
} catch {
|
||||
if (scope.signal.aborted || request !== optionsRequest) return;
|
||||
optionsStatus.textContent = 'Sources and folders could not be loaded. Refresh to retry.';
|
||||
if (scope.signal.aborted || request !== optionsRequest) return false;
|
||||
optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : 'Sources and folders could not be loaded. Refresh to retry.';
|
||||
optionsStatus.hidden = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
scope.cleanup(() => optionsRequest?.abort());
|
||||
scope.listen(search, 'input', scope.debounce(() => { filters.q = search.value.trim(); reset(); }, 200));
|
||||
scope.listen(search, 'input', scope.debounce(() => { if (!recovering) { filters.q = search.value.trim(); reset(); } }, 200));
|
||||
for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) {
|
||||
scope.listen(input, 'change', () => {
|
||||
if (recovering) return;
|
||||
filters[key] = input.value;
|
||||
if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); }
|
||||
reset();
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
|
||||
.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; }
|
||||
.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; }
|
||||
.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; }
|
||||
.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; }
|
||||
.file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.file-status { padding: 17px; }
|
||||
|
||||
Reference in New Issue
Block a user