fix(annotations): re-read a tagged file in place after a save

After a tag save the selected file's inspection is re-read quietly: the
panel and the viewer keep the current details until the new answer lands,
with no "Loading file details…" in between. While a Tags filter or a
search is active, Images re-reads its loaded range in place (same query,
from the first page up to the loaded count, published once), keeping the
cards, the scroll position and the selection while the selected file still
matches; when it no longer matches, the panel follows the collection.
This commit is contained in:
Vito Sansevero
2026-10-22 13:39:39 -07:00
parent e6f4bf9214
commit 024ccd3acf
6 changed files with 260 additions and 12 deletions
+49 -1
View File
@@ -110,8 +110,11 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
onChange(state);
}
// A refresh in flight (its abort controller): Load more waits for it, a reset or dispose cancels it.
let refreshing = null;
async function loadMore() {
if (disposed || state.loading || !state.hasMore) return 0;
if (disposed || state.loading || !state.hasMore || refreshing) return 0;
const request = new AbortController();
controller = request;
const cursor = state.cursor;
@@ -148,11 +151,53 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
}
}
/**
* Re-read the loaded range of the same query (a write may have changed which files match) and publish the new
* collection once, with the hint `{replaced: true}`; the current items stay shown until then. A failure, a reset
* or a dispose leaves the collection as it was. Resolves to the number of items now loaded (0 when abandoned).
*/
async function refresh() {
if (disposed || state.loading) return 0;
refreshing?.abort();
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;
try {
while (items.length < target && hasMore) {
const parameters = new URLSearchParams(query);
if (cursor !== null) parameters.set('cursor', cursor);
const page = await readPage({parameters, signal:request.signal});
if (disposed || request !== refreshing) return 0;
validatePage(page, cursor);
for (const item of page.images) {
const key = imageKey(item);
if (keys.has(key)) continue;
keys.add(key);
items.push(Object.freeze({ ...item }));
}
if (cursor === null) positiveCount = isCount(page.positive_count) ? page.positive_count : null;
({total, next_cursor: cursor, has_more: hasMore} = page);
}
} catch {
if (request === refreshing) refreshing = null;
return 0;
}
refreshing = null;
seen = keys;
state = Object.freeze({ ...state, items: Object.freeze(items), total, cursor, hasMore, loading: false, error: null,
loaded: items.length, positiveCount });
onChange(state, {replaced: true});
return items.length;
}
async function reset(filters = {}, {load = true} = {}) {
if (disposed) return 0;
const nextQuery = queryFor(filters, batchSize);
controller?.abort();
controller = null;
refreshing?.abort();
refreshing = null;
query = nextQuery;
seen = new Set();
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
@@ -164,6 +209,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
get state() { return state; },
reset,
loadMore,
refresh,
/** Patch one loaded item (a favorite toggle); false when it is not loaded or the stream is gone. */
updateItem(key, patch) {
const next = withItem(state, key, patch);
@@ -178,6 +224,8 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
disposed = true;
controller?.abort();
controller = null;
refreshing?.abort();
refreshing = null;
},
};
}
+21 -7
View File
@@ -410,9 +410,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
else if (isTagButton(action)) openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
else if (action) inspect(image);
});
// A saved tag set re-reads the selected file's details (its inspector envelope carries the image tags).
scope.cleanup(onImageTagsSaved(({target}) => {
if (selection === `${target.root_index}:${target.path}`) inspection.retry();
// A saved tag set re-reads the selected file's details (its inspector envelope carries the image tags) without a
// loading flash. Under a Tags filter or a search the saved file may now match or stop matching: the loaded range
// is re-read in place first, keeping the scroll and the selection while it still matches.
scope.cleanup(onImageTagsSaved(async ({target}) => {
const key = `${target.root_index}:${target.path}`;
if (!embedded && !recovering && (filters.tags.length || filters.q)) await stream.refresh();
if (!scope.signal.aborted && selection === key) inspection.refresh();
}));
function renderVisible() {
@@ -548,14 +552,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The concept's end of the collection (app.js updateImageContinuation).
return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`;
}
function renderState(next, {patch} = {}) {
function renderState(next, {patch, replaced = false} = {}) {
const previous = state;
// A patched item (a favorite) keeps every card's size and place; setFavorite repaints its card.
const changed = !patch && state?.items !== next.items;
if (changed) {
if (!next.items.length) itemIndexes.clear();
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
// A refreshed collection (`replaced`) is indexed again from its start; a loaded page only appends.
if (!next.items.length || replaced) itemIndexes.clear();
for (let index = replaced ? 0 : state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
// The selected file left the refreshed result: the panel follows the collection, as after a reload.
if (replaced && selection !== null && !itemIndexes.has(selection)) dropSelection();
state = next;
if (!embedded) {
setText(count, countLine(next));
@@ -564,7 +571,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
if (changed) updateLayout();
// The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection.
if (!embedded && changed && previous?.loaded && next.loaded > previous.loaded) {
if (!embedded && changed && !replaced && previous?.loaded && next.loaded > previous.loaded) {
const added = next.loaded - previous.loaded;
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
}
@@ -624,6 +631,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
toolbar?.setActiveFilters(activeRefinements(filters));
}
function dropSelection() {
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
details.replaceChildren(noSelection());
selection = null;
}
function reset() {
mediaViewer.stopSlideshow();
main.querySelector('#viewer-dialog[open]')?.close();
+10 -4
View File
@@ -317,11 +317,12 @@ export const rootedFileAdapter = Object.freeze({
export function createInspectionLoader(api, onChange, { extractFacts, adapter = rootedFileAdapter } = {}) {
let request = null, disposed = false;
return {
async load(image) {
/** `quiet`: publish only the answer, so the shown record stays until it lands (a re-read after a write). */
async load(image, { quiet = false } = {}) {
if (disposed) return;
request?.abort();
const current = request = new AbortController();
onChange({ loading: true });
if (!quiet) onChange({ loading: true });
try {
const result = await api.get(adapter.inspectionPath(image), { signal: current.signal });
if (disposed || request !== current) return;
@@ -354,8 +355,8 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
}
const makeLoader = () => createInspectionLoader(api, publish, { extractFacts, adapter });
let loader = makeLoader();
function read(image) {
pending = loader.load(image);
function read(image, options) {
pending = loader.load(image, options);
return pending;
}
return {
@@ -379,6 +380,11 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
if (disposed || !hasAttempt || state.refreshRequired) return;
return read(attempted);
},
/** Re-read the selected file keeping the shown record until the answer (a retry when none is shown). */
refresh() {
if (disposed || !hasAttempt || state.refreshRequired) return;
return read(attempted, { quiet: Boolean(state.record) && !state.loading && !state.error });
},
clear() {
if (disposed) return;
loader.dispose(); loader = makeLoader(); current = null; pending = null; attempted = undefined; hasAttempt = false;