mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-16 03:40:37 +08:00
Extract shared paginated list state helper
This commit is contained in:
@@ -35,7 +35,3 @@ export function saveListState(key: string, state: ListState): void {
|
||||
export function readListState<T>(key: string): ListState<T> | undefined {
|
||||
return states.get(key) as ListState<T> | undefined;
|
||||
}
|
||||
|
||||
export function clearListState(key: string): void {
|
||||
states.delete(key);
|
||||
}
|
||||
|
||||
@@ -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<T> {
|
||||
// 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<T[]>;
|
||||
// Fetch a page from the backend; `take === undefined` means all rows.
|
||||
fetchPage: (
|
||||
skip: number,
|
||||
take: number | undefined,
|
||||
) => Promise<PaginatedQuery<T[]>>;
|
||||
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<T>(opts: PaginatedListOptions<T>) {
|
||||
// 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<T>(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> => 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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
+27
-128
@@ -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<Issue>(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> => 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<Issue>({
|
||||
key: () => listKey(status),
|
||||
page: () => issues,
|
||||
fetchPage: (skip, take) =>
|
||||
invoke<PaginatedQuery<Issue[]>>("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<PaginatedQuery<Issue[]>>("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<void> {
|
||||
if (!more) return;
|
||||
loadingMore = true;
|
||||
let superseded = false;
|
||||
try {
|
||||
const page = await loader.run(async () => {
|
||||
return await invoke<PaginatedQuery<Issue[]>>("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 @@
|
||||
</div>
|
||||
<div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
|
||||
<FuzzySearch
|
||||
hasItems={items.length > 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 @@
|
||||
<div
|
||||
class="txt-missing txt-body-m-regular global-flex"
|
||||
style:gap="0.25rem">
|
||||
{#if items.length > 0}
|
||||
{#if list.items.length > 0}
|
||||
No matching issues
|
||||
{:else}
|
||||
No {status === "all" ? "" : status} issues
|
||||
@@ -405,15 +304,15 @@
|
||||
</div>
|
||||
<VirtualList
|
||||
items={searchResults}
|
||||
hasMore={more}
|
||||
{loadingMore}
|
||||
onLoadMore={() => 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)}
|
||||
<div class="row">
|
||||
<IssueTeaser
|
||||
|
||||
+27
-128
@@ -11,9 +11,7 @@
|
||||
import delay from "lodash/delay";
|
||||
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import type { ListCacheSnapshot } from "@app/lib/listState";
|
||||
import { readListState, saveListState } from "@app/lib/listState";
|
||||
import * as mutexExecutor from "@app/lib/mutexExecutor";
|
||||
import { createPaginatedList } from "@app/lib/paginatedList.svelte";
|
||||
import {
|
||||
patchCountMismatch,
|
||||
resetPatchCounts,
|
||||
@@ -45,29 +43,6 @@
|
||||
return `repo.patches:${repo.rid}:${filter ?? "all"}`;
|
||||
}
|
||||
|
||||
// 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<Patch>(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> => 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<Patch>({
|
||||
key: () => listKey(status),
|
||||
page: () => patches,
|
||||
fetchPage: (skip, take) =>
|
||||
invoke<PaginatedQuery<Patch[]>>("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<PaginatedQuery<Patch[]>>("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<void> {
|
||||
if (!more) return;
|
||||
loadingMore = true;
|
||||
let superseded = false;
|
||||
try {
|
||||
const page = await loader.run(async () => {
|
||||
return await invoke<PaginatedQuery<Patch[]>>("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 @@
|
||||
</div>
|
||||
<div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
|
||||
<FuzzySearch
|
||||
hasItems={items.length > 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 @@
|
||||
<div
|
||||
class="txt-missing txt-body-m-regular global-flex"
|
||||
style:gap="0.25rem">
|
||||
{#if items.length > 0}
|
||||
{#if list.items.length > 0}
|
||||
No matching patches
|
||||
{:else}
|
||||
No {status === undefined ? "" : status} patches
|
||||
@@ -433,15 +332,15 @@
|
||||
</div>
|
||||
<VirtualList
|
||||
items={searchResults}
|
||||
hasMore={more}
|
||||
{loadingMore}
|
||||
onLoadMore={() => 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)}
|
||||
<div class="row">
|
||||
<PatchTeaser
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
import fuzzysort from "fuzzysort";
|
||||
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import type { ListCacheSnapshot } from "@app/lib/listState";
|
||||
import { readListState, saveListState } from "@app/lib/listState";
|
||||
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";
|
||||
|
||||
@@ -50,60 +48,26 @@
|
||||
return `repo.commits:${repo.rid}:${peer ?? ""}:${revision ?? ""}`;
|
||||
}
|
||||
|
||||
// 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<Commit>(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> => 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<Commit>({
|
||||
key: () => listKey(),
|
||||
page: () => commits,
|
||||
fetchPage: (skip, take) =>
|
||||
invoke<PaginatedQuery<Commit[]>>("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<void> {
|
||||
if (!more) return;
|
||||
loadingMore = true;
|
||||
let superseded = false;
|
||||
try {
|
||||
const page = await loader.run(async () => {
|
||||
return await invoke<PaginatedQuery<Commit[]>>("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()}
|
||||
<FuzzySearch
|
||||
hasItems={items.length > 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 @@
|
||||
<div
|
||||
class="txt-missing txt-body-m-regular global-flex"
|
||||
style:gap="0.25rem">
|
||||
{#if items.length > 0}
|
||||
{#if list.items.length > 0}
|
||||
No matching commits
|
||||
{:else}
|
||||
No commits
|
||||
@@ -336,14 +267,14 @@
|
||||
{:else}
|
||||
<VirtualList
|
||||
items={commitRows}
|
||||
hasMore={more}
|
||||
{loadingMore}
|
||||
onLoadMore={() => 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"}
|
||||
<div class="group-title">{item.label}</div>
|
||||
|
||||
Reference in New Issue
Block a user