diff --git a/src/lib/listState.ts b/src/lib/listState.ts index 4b444b424..1d37874ef 100644 --- a/src/lib/listState.ts +++ b/src/lib/listState.ts @@ -35,7 +35,3 @@ export function saveListState(key: string, state: ListState): void { export function readListState(key: string): ListState | undefined { return states.get(key) as ListState | undefined; } - -export function clearListState(key: string): void { - states.delete(key); -} diff --git a/src/lib/paginatedList.svelte.ts b/src/lib/paginatedList.svelte.ts new file mode 100644 index 000000000..e1a35a2e4 --- /dev/null +++ b/src/lib/paginatedList.svelte.ts @@ -0,0 +1,159 @@ +import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery"; + +import type { ListCacheSnapshot } from "@app/lib/listState"; +import { readListState, saveListState } from "@app/lib/listState"; +import * as mutexExecutor from "@app/lib/mutexExecutor"; +import * as router from "@app/lib/router"; + +export interface PaginatedListOptions { + // Scroll-restoration cache key; changes when the list's filter changes. + key: () => string; + // The route loader's freshly fetched first page (reactive prop access). + page: () => PaginatedQuery; + // Fetch a page from the backend; `take === undefined` means all rows. + fetchPage: ( + skip: number, + take: number | undefined, + ) => Promise>; + pageSize: number; + // Stable identity used to dedupe overlapping pages. + id: (item: T) => string; + // Skip persisting scroll state (e.g. while a fuzzy filter is active, since + // the rendered rows don't match the cached item list). + skipPersist?: () => boolean; +} + +// Paginated list state shared by the Issues/Patches/Commits views: an +// incrementally loaded item list with mutex-guarded page fetches, plus scroll +// restoration for back/forward navigation. Must be called during component +// init (it registers an $effect). +export function createPaginatedList(opts: PaginatedListOptions) { + // Restore the prior list and scroll position only when arriving via + // back/forward; a fresh navigation (sidebar, links) starts from the top. + const restored = router.isHistoryNavigation() + ? readListState(opts.key()) + : undefined; + + let items = $state(restored?.items ?? opts.page().content); + // Rows fetched so far — used as the pagination offset. Tracked separately + // from items.length because appends are deduped (overlapping pages, e.g. + // when the list grows underneath us, must still advance the offset). + let cursor = (restored?.items ?? opts.page().content).length; + let more = $state(restored?.more ?? opts.page().more); + let loadingMore = $state(false); + let activeKey = opts.key(); + + const loader = mutexExecutor.create(); + const abort = async (): Promise => undefined; + + // Parent reuses the view across filter changes; a key change resets the + // pagination state from the loader's fresh page. + $effect(() => { + const key = opts.key(); + const fresh = opts.page(); + // Skip the initial mount (state is seeded above, possibly restored); only + // reset when the key actually changes. + if (key === activeKey) return; + activeKey = key; + items = fresh.content; + cursor = fresh.content.length; + more = fresh.more; + // Abort any in-flight loadMore so it cannot append a page from the + // previous filter onto the just-reset items. The aborted call leaves + // `loadingMore` to its superseder — and here that superseder is this + // abort task, which never clears it, so reset the flag explicitly or + // load-more stays disabled for the new list forever. + void loader.run(abort); + loadingMore = false; + }); + + async function loadMore(all: boolean = false): Promise { + if (!more) return; + loadingMore = true; + let superseded = false; + try { + const page = await loader.run(async () => + opts.fetchPage(all ? 0 : cursor, all ? undefined : opts.pageSize), + ); + + // Superseded by a newer load (e.g. fuzzy-focus triggered a load-all). + // Leave items/more alone for the new call. The flag stays set too: the + // newer call owns it now, and clearing it here would let the + // virtualizer's auto load-more re-fire and abort that call in turn. + if (page === undefined) { + superseded = true; + return; + } + + more = page.more; + if (all) { + items = page.content; + cursor = page.content.length; + } else { + // Drop ids already shown so duplicate keys never reach the virtual + // list (which would leave blank, persistent gaps); still advance the + // offset by the rows fetched so paging keeps moving forward. + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local set, never rendered + const seen = new Set(items.map(opts.id)); + items = [...items, ...page.content.filter(i => !seen.has(opts.id(i)))]; + cursor += page.content.length; + } + if (page.content.length === 0) more = false; + } catch (error) { + // Callers fire-and-forget (the virtualizer's load-more trigger), so + // failures are logged here instead of surfacing as unhandled + // rejections; the finally below re-enables the next attempt. + console.error("Loading more list items failed: ", error); + } finally { + if (!superseded) { + loadingMore = false; + } + } + } + + // Refetch the first page and replace the list (e.g. after a cache rebuild), + // aborting any in-flight page load. + async function reload(): Promise { + const page = await loader.run(async () => + opts.fetchPage(0, opts.pageSize), + ); + if (page !== undefined) { + items = page.content; + cursor = page.content.length; + more = page.more; + } + } + + // Persist the loaded list + scroll position so back/forward can restore it. + // Only the unfiltered list is cached, so its length matches virtua's size + // snapshot. + function persistScroll(state: { + scrollOffset: number; + cache: ListCacheSnapshot; + }): void { + if (opts.skipPersist?.()) return; + saveListState(opts.key(), { + items: [...items], + more, + scrollOffset: state.scrollOffset, + cache: state.cache, + }); + } + + return { + get items() { + return items; + }, + get more() { + return more; + }, + get loadingMore() { + return loadingMore; + }, + initialScrollOffset: restored?.scrollOffset, + initialCache: restored?.cache, + loadMore, + reload, + persistScroll, + }; +} diff --git a/src/views/repo/Issues.svelte b/src/views/repo/Issues.svelte index 0dcd16bfa..17ace4ebc 100644 --- a/src/views/repo/Issues.svelte +++ b/src/views/repo/Issues.svelte @@ -16,10 +16,8 @@ resetIssueCounts, updateIssueCounts, } from "@app/lib/issueCounts.svelte"; - import type { ListCacheSnapshot } from "@app/lib/listState"; - import { readListState, saveListState } from "@app/lib/listState"; import { show } from "@app/lib/modal"; - import * as mutexExecutor from "@app/lib/mutexExecutor"; + import { createPaginatedList } from "@app/lib/paginatedList.svelte"; import * as router from "@app/lib/router"; import { modifierKey } from "@app/lib/utils"; @@ -47,29 +45,6 @@ return `repo.issues:${repo.rid}:${filter}`; } - // Restore the prior list and scroll position only when arriving via - // back/forward; a fresh navigation (sidebar, links) starts from the top. - // svelte-ignore state_referenced_locally - const restored = router.isHistoryNavigation() - ? readListState(listKey(status)) - : undefined; - - // Parent reuses this component across status filter changes; a sibling - // $effect resets pagination state when the filter changes. - // svelte-ignore state_referenced_locally - let items = $state(restored?.items ?? issues.content); - // Rows fetched so far — used as the pagination offset. Tracked separately - // from items.length because appends are deduped (overlapping pages, e.g. when - // the list grows underneath us, must still advance the offset). - // svelte-ignore state_referenced_locally - let cursor = $state((restored?.items ?? issues.content).length); - // svelte-ignore state_referenced_locally - let more = $state(restored?.more ?? issues.more); - const initialScrollOffset = restored?.scrollOffset; - const initialCache = restored?.cache; - // svelte-ignore state_referenced_locally - let activeKey = listKey(status); - let loadingMore = $state(false); let loading = $state(false); let searchInput = $state(""); let debouncedSearch = $state(""); @@ -81,44 +56,25 @@ const project = $derived(repo.payloads["xyz.radicle.project"]!); - const loader = mutexExecutor.create(); - const abort = async (): Promise => undefined; - - $effect(() => { - const key = listKey(status); - const fresh = issues; - // Skip the initial mount (state is seeded above, possibly restored); only - // reset when the filter actually changes. - if (key === activeKey) return; - activeKey = key; - items = fresh.content; - cursor = fresh.content.length; - more = fresh.more; - // Abort any in-flight loadMoreContent so it cannot append a page - // from the previous filter onto the just-reset items. - void loader.run(abort); + const list = createPaginatedList({ + key: () => listKey(status), + page: () => issues, + fetchPage: (skip, take) => + invoke>("list_issues", { + rid: repo.rid, + status, + skip, + take, + }), + pageSize: DEFAULT_TAKE, + id: issue => issue.id, + skipPersist: () => searchInput !== "", }); - // Persist the loaded list + scroll position so back/forward can restore it. - // Only the unfiltered list is cached, so its length matches virtua's size - // snapshot. - function persistScroll(state: { - scrollOffset: number; - cache: ListCacheSnapshot; - }) { - if (searchInput !== "") return; - saveListState(listKey(status), { - items: [...items], - more, - scrollOffset: state.scrollOffset, - cache: state.cache, - }); - } - $effect(() => { - if (more === false) { + if (list.more === false) { updateIssueCounts( - items.length, + list.items.length, { ...project.meta.issues, all: project.meta.issues.open + project.meta.issues.closed, @@ -155,21 +111,7 @@ } catch (error) { console.error(error); } finally { - const page = await loader.run(async () => { - return await invoke>("list_issues", { - rid: repo.rid, - skip: 0, - status, - take: DEFAULT_TAKE, - }); - }); - - if (page !== undefined) { - items = page.content; - cursor = page.content.length; - more = page.more; - } - + await list.reload(); resetIssueCounts(); delay(() => { @@ -178,51 +120,8 @@ } } - async function loadMoreContent(all: boolean = false): Promise { - if (!more) return; - loadingMore = true; - let superseded = false; - try { - const page = await loader.run(async () => { - return await invoke>("list_issues", { - rid: repo.rid, - status, - skip: all ? 0 : cursor, - take: all ? undefined : DEFAULT_TAKE, - }); - }); - - // Superseded by a newer load (e.g. fuzzy-focus triggered a load-all). - // Leave items/more alone for the new call. The flag stays set too: the - // newer call owns it now, and clearing it here would let the - // virtualizer's auto load-more re-fire and abort that call in turn. - if (page === undefined) { - superseded = true; - return; - } - - more = page.more; - if (all) { - items = page.content; - cursor = page.content.length; - } else { - // Drop ids already shown so duplicate keys never reach the virtual - // list (which would leave blank, persistent gaps); still advance the - // offset by the rows fetched so paging keeps moving forward. - const seen = new Set(items.map(i => i.id)); - items = [...items, ...page.content.filter(i => !seen.has(i.id))]; - cursor += page.content.length; - } - if (page.content.length === 0) more = false; - } finally { - if (!superseded) { - loadingMore = false; - } - } - } - const searchableIssues = $derived( - items + list.items .flatMap(i => { return { issue: i, @@ -333,13 +232,13 @@
0} + hasItems={list.items.length > 0} placeholder={`Fuzzy filter issues ${modifierKey()} + f`} icon={loading ? "clock" : "filter"} onFocus={async () => { try { loading = true; - await loadMoreContent(true); + await list.loadMore(true); } catch (e) { console.error("Loading all issues failed: ", e); } finally { @@ -386,7 +285,7 @@
- {#if items.length > 0} + {#if list.items.length > 0} No matching issues {:else} No {status === "all" ? "" : status} issues @@ -405,15 +304,15 @@
loadMoreContent()} + hasMore={list.more} + loadingMore={list.loadingMore} + onLoadMore={() => list.loadMore()} startMargin={chromeHeight} estimatedItemSize={80} getKey={result => result.obj.issue.id} - {initialCache} - {initialScrollOffset} - onState={persistScroll}> + initialCache={list.initialCache} + initialScrollOffset={list.initialScrollOffset} + onState={list.persistScroll}> {#snippet row(result)}
(listKey(status)) - : undefined; - - // Parent reuses this component across status filter changes; a sibling - // $effect resets pagination state when the filter changes. - // svelte-ignore state_referenced_locally - let items = $state(restored?.items ?? patches.content); - // Rows fetched so far — used as the pagination offset. Tracked separately - // from items.length because appends are deduped (overlapping pages, e.g. when - // the list grows underneath us, must still advance the offset). - // svelte-ignore state_referenced_locally - let cursor = $state((restored?.items ?? patches.content).length); - // svelte-ignore state_referenced_locally - let more = $state(restored?.more ?? patches.more); - const initialScrollOffset = restored?.scrollOffset; - const initialCache = restored?.cache; - // svelte-ignore state_referenced_locally - let activeKey = listKey(status); - let loadingMore = $state(false); let loading = $state(false); let searchInput = $state(""); let debouncedSearch = $state(""); @@ -79,43 +54,24 @@ const project = $derived(repo.payloads["xyz.radicle.project"]!); - const loader = mutexExecutor.create(); - const abort = async (): Promise => undefined; - - $effect(() => { - const key = listKey(status); - const fresh = patches; - // Skip the initial mount (state is seeded above, possibly restored); only - // reset when the filter actually changes. - if (key === activeKey) return; - activeKey = key; - items = fresh.content; - cursor = fresh.content.length; - more = fresh.more; - // Abort any in-flight loadMoreContent so it cannot append a page - // from the previous filter onto the just-reset items. - void loader.run(abort); + const list = createPaginatedList({ + key: () => listKey(status), + page: () => patches, + fetchPage: (skip, take) => + invoke>("list_patches", { + rid: repo.rid, + status, + skip, + take, + }), + pageSize: DEFAULT_TAKE, + id: patch => patch.id, + skipPersist: () => searchInput !== "", }); - // Persist the loaded list + scroll position so back/forward can restore it. - // Only the unfiltered list is cached, so its length matches virtua's size - // snapshot. - function persistScroll(state: { - scrollOffset: number; - cache: ListCacheSnapshot; - }) { - if (searchInput !== "") return; - saveListState(listKey(status), { - items: [...items], - more, - scrollOffset: state.scrollOffset, - cache: state.cache, - }); - } - $effect(() => { - if (more === false) { - updatePatchCounts(items.length, project.meta.patches, status); + if (list.more === false) { + updatePatchCounts(list.items.length, project.meta.patches, status); } }); @@ -146,21 +102,7 @@ } catch (error) { console.error(error); } finally { - const page = await loader.run(async () => { - return await invoke>("list_patches", { - rid: repo.rid, - skip: 0, - status, - take: DEFAULT_TAKE, - }); - }); - - if (page !== undefined) { - items = page.content; - cursor = page.content.length; - more = page.more; - } - + await list.reload(); resetPatchCounts(); delay(() => { @@ -169,51 +111,8 @@ } } - async function loadMoreContent(all: boolean = false): Promise { - if (!more) return; - loadingMore = true; - let superseded = false; - try { - const page = await loader.run(async () => { - return await invoke>("list_patches", { - rid: repo.rid, - status, - skip: all ? 0 : cursor, - take: all ? undefined : DEFAULT_TAKE, - }); - }); - - // Superseded by a newer load (e.g. fuzzy-focus triggered a load-all). - // Leave items/more alone for the new call. The flag stays set too: the - // newer call owns it now, and clearing it here would let the - // virtualizer's auto load-more re-fire and abort that call in turn. - if (page === undefined) { - superseded = true; - return; - } - - more = page.more; - if (all) { - items = page.content; - cursor = page.content.length; - } else { - // Drop ids already shown so duplicate keys never reach the virtual - // list (which would leave blank, persistent gaps); still advance the - // offset by the rows fetched so paging keeps moving forward. - const seen = new Set(items.map(i => i.id)); - items = [...items, ...page.content.filter(i => !seen.has(i.id))]; - cursor += page.content.length; - } - if (page.content.length === 0) more = false; - } finally { - if (!superseded) { - loadingMore = false; - } - } - } - const searchablePatches = $derived( - items + list.items .flatMap(i => { return { patch: i, @@ -369,13 +268,13 @@
0} + hasItems={list.items.length > 0} placeholder={`Fuzzy filter patches ${modifierKey()} + f`} icon={loading ? "clock" : "filter"} onFocus={async () => { try { loading = true; - await loadMoreContent(true); + await list.loadMore(true); } catch (e) { console.error("Loading all patches failed: ", e); } finally { @@ -414,7 +313,7 @@
- {#if items.length > 0} + {#if list.items.length > 0} No matching patches {:else} No {status === undefined ? "" : status} patches @@ -433,15 +332,15 @@
loadMoreContent()} + hasMore={list.more} + loadingMore={list.loadingMore} + onLoadMore={() => list.loadMore()} startMargin={chromeHeight} estimatedItemSize={80} getKey={result => result.obj.patch.id} - {initialCache} - {initialScrollOffset} - onState={persistScroll}> + initialCache={list.initialCache} + initialScrollOffset={list.initialScrollOffset} + onState={list.persistScroll}> {#snippet row(result)}
(listKey()) - : undefined; - - // Parent reuses this component across navigations; a sibling $effect resets - // pagination state when the peer/revision changes. - // svelte-ignore state_referenced_locally - let items = $state(restored?.items ?? commits.content); - // svelte-ignore state_referenced_locally - let more = $state(restored?.more ?? commits.more); - const initialScrollOffset = restored?.scrollOffset; - const initialCache = restored?.cache; - let activeKey = listKey(); - let loadingMore = $state(false); let loading = $state(false); let searchInput = $state(""); let debouncedSearch = $state(""); let showSearch = $state(false); - const loader = mutexExecutor.create(); - const abort = async (): Promise => undefined; - - $effect(() => { - const key = listKey(); - const fresh = commits; - // Skip the initial mount (state is seeded above, possibly restored); only - // reset when the peer/revision changes. - if (key === activeKey) return; - activeKey = key; - items = fresh.content; - more = fresh.more; - // Abort any in-flight loadMoreContent so it cannot append a page - // from the previous navigation onto the just-reset items. - void loader.run(abort); + const list = createPaginatedList({ + key: () => listKey(), + page: () => commits, + fetchPage: (skip, take) => + invoke>("list_repo_commits", { + rid: repo.rid, + head: oid, + skip, + take, + }), + pageSize: COMMITS_PAGE_SIZE, + id: commit => commit.id, + skipPersist: () => searchInput !== "", }); - // Persist the loaded list + scroll position so back/forward can restore it. - // Only the unfiltered list is cached, so its length matches virtua's size - // snapshot. - function persistScroll(state: { - scrollOffset: number; - cache: ListCacheSnapshot; - }) { - if (searchInput !== "") return; - saveListState(listKey(), { - items: [...items], - more, - scrollOffset: state.scrollOffset, - cache: state.cache, - }); - } - $effect(() => { const value = searchInput; const timer = setTimeout(() => { @@ -112,39 +76,6 @@ return () => clearTimeout(timer); }); - async function loadMoreContent(all: boolean = false): Promise { - if (!more) return; - loadingMore = true; - let superseded = false; - try { - const page = await loader.run(async () => { - return await invoke>("list_repo_commits", { - rid: repo.rid, - head: oid, - skip: all ? 0 : items.length, - take: all ? undefined : COMMITS_PAGE_SIZE, - }); - }); - - // Superseded by a newer load (e.g. fuzzy-focus triggered a load-all). - // Leave items/more alone for the new call. The flag stays set too: the - // newer call owns it now, and clearing it here would let the - // virtualizer's auto load-more re-fire and abort that call in turn. - if (page === undefined) { - superseded = true; - return; - } - - more = page.more; - items = all ? page.content : [...items, ...page.content]; - if (page.content.length === 0) more = false; - } finally { - if (!superseded) { - loadingMore = false; - } - } - } - function dayKey(timestamp: number) { const date = new Date(timestamp); const month = `${date.getMonth() + 1}`.padStart(2, "0"); @@ -178,7 +109,7 @@ } const searchableCommits = $derived( - items.map(c => ({ + list.items.map(c => ({ commit: c, id: c.id, summary: c.summary, @@ -289,14 +220,14 @@ active="commits"> {#snippet extra()} 0} + hasItems={list.items.length > 0} placeholder={`Fuzzy filter commits ${modifierKey()} + f`} styleHeight="1.75rem" icon={loading ? "clock" : "filter"} onFocus={async () => { try { loading = true; - await loadMoreContent(true); + await list.loadMore(true); } catch (e) { console.error("Loading all commits failed: ", e); } finally { @@ -326,7 +257,7 @@
- {#if items.length > 0} + {#if list.items.length > 0} No matching commits {:else} No commits @@ -336,14 +267,14 @@ {:else} loadMoreContent()} + hasMore={list.more} + loadingMore={list.loadingMore} + onLoadMore={() => list.loadMore()} getKey={r => r.type === "header" ? `h:${r.key}` : `c:${r.commit.id}`} - {initialCache} - {initialScrollOffset} - onState={persistScroll}> + initialCache={list.initialCache} + initialScrollOffset={list.initialScrollOffset} + onState={list.persistScroll}> {#snippet row(item)} {#if item.type === "header"}
{item.label}