mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-31 01:00:47 +08:00
Restore list scroll position on back/forward
Navigating away from a list and back via history (the sidebar back/forward buttons) returns to the same scroll position with the previously loaded pages intact, instead of refetching only the first page at the top.
This commit is contained in:
@@ -74,7 +74,7 @@ impl PatchStorage for Sqlite {
|
||||
) AS last_revision_timestamp
|
||||
FROM patches AS p
|
||||
WHERE repo = ?1
|
||||
ORDER BY last_revision_timestamp DESC;
|
||||
ORDER BY last_revision_timestamp DESC, id DESC;
|
||||
",
|
||||
)?;
|
||||
stmt.bind((1, &rid))?;
|
||||
@@ -99,7 +99,7 @@ impl PatchStorage for Sqlite {
|
||||
FROM patches AS p
|
||||
WHERE repo = ?1
|
||||
AND patch->>'$.state.status' = ?2
|
||||
ORDER BY last_revision_timestamp DESC;
|
||||
ORDER BY last_revision_timestamp DESC, id DESC;
|
||||
",
|
||||
)?;
|
||||
stmt.bind((1, &rid))?;
|
||||
@@ -125,7 +125,7 @@ impl IssueStorage for Sqlite {
|
||||
) AS created_timestamp
|
||||
FROM issues AS i
|
||||
WHERE repo = ?1
|
||||
ORDER BY created_timestamp DESC;
|
||||
ORDER BY created_timestamp DESC, id DESC;
|
||||
",
|
||||
)?;
|
||||
stmt.bind((1, &rid))?;
|
||||
@@ -150,7 +150,7 @@ impl IssueStorage for Sqlite {
|
||||
FROM issues AS i
|
||||
WHERE repo = ?1
|
||||
AND issue->>'$.state.status' = ?2
|
||||
ORDER BY created_timestamp DESC;
|
||||
ORDER BY created_timestamp DESC, id DESC;
|
||||
",
|
||||
)?;
|
||||
stmt.bind((1, &rid))?;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { VirtualizerHandle } from "virtua/svelte";
|
||||
|
||||
import { Virtualizer } from "virtua/svelte";
|
||||
|
||||
import type { ListCacheSnapshot } from "@app/lib/listState";
|
||||
|
||||
import { getScrollViewport } from "@app/components/ScrollArea.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -20,6 +23,14 @@
|
||||
startMargin?: number;
|
||||
// Distance from the bottom (in pixels) at which to request the next page.
|
||||
prefetchPx?: number;
|
||||
// Restore a previously-captured scroll position (used on history nav).
|
||||
initialCache?: ListCacheSnapshot;
|
||||
initialScrollOffset?: number;
|
||||
// Reports the current scroll position so callers can persist it.
|
||||
onState?: (state: {
|
||||
scrollOffset: number;
|
||||
cache: ListCacheSnapshot;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -33,6 +44,9 @@
|
||||
bufferSize = undefined,
|
||||
startMargin = undefined,
|
||||
prefetchPx = 800,
|
||||
initialCache = undefined,
|
||||
initialScrollOffset = undefined,
|
||||
onState = undefined,
|
||||
}: Props = $props();
|
||||
|
||||
const getViewport = getScrollViewport();
|
||||
@@ -40,6 +54,27 @@
|
||||
// `initialized`, which is deferred).
|
||||
const viewport = $derived(getViewport());
|
||||
|
||||
let handle = $state<VirtualizerHandle | undefined>(undefined);
|
||||
let restored = false;
|
||||
|
||||
$effect(() => {
|
||||
// Restore the saved scroll offset once, after the virtualizer has mounted
|
||||
// with the restored size cache (so the offset maps to the right item).
|
||||
if (restored || !handle) return;
|
||||
restored = true;
|
||||
if (initialScrollOffset) {
|
||||
handle.scrollTo(initialScrollOffset);
|
||||
}
|
||||
});
|
||||
|
||||
function reportState() {
|
||||
if (!handle || !onState) return;
|
||||
onState({
|
||||
scrollOffset: handle.getScrollOffset(),
|
||||
cache: handle.getCache(),
|
||||
});
|
||||
}
|
||||
|
||||
function maybeLoadMore() {
|
||||
if (!onLoadMore || !hasMore || loadingMore) return;
|
||||
const el = viewport;
|
||||
@@ -62,13 +97,16 @@
|
||||
|
||||
{#if viewport}
|
||||
<Virtualizer
|
||||
bind:this={handle}
|
||||
data={items}
|
||||
scrollRef={viewport}
|
||||
{getKey}
|
||||
itemSize={estimatedItemSize}
|
||||
{bufferSize}
|
||||
{startMargin}
|
||||
onscroll={maybeLoadMore}>
|
||||
cache={initialCache}
|
||||
onscroll={maybeLoadMore}
|
||||
onscrollend={reportState}>
|
||||
{#snippet children(item: T, index: number)}
|
||||
{@render row(item, index)}
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { VirtualizerHandle } from "virtua/svelte";
|
||||
|
||||
// virtua's cache snapshot is opaque; derive its type from the handle so we
|
||||
// don't depend on a non-public export.
|
||||
export type ListCacheSnapshot = ReturnType<VirtualizerHandle["getCache"]>;
|
||||
|
||||
export interface ListState<T = unknown> {
|
||||
// The full set of loaded (paginated) items, so a restored list isn't limited
|
||||
// to the first page the route loader re-fetches.
|
||||
items: T[];
|
||||
more: boolean;
|
||||
scrollOffset: number;
|
||||
// virtua measurement snapshot, so item sizes (and thus the offset) are exact
|
||||
// immediately on restore instead of drifting as rows are re-measured.
|
||||
cache: ListCacheSnapshot | undefined;
|
||||
}
|
||||
|
||||
// Keep only a handful of recently-visited lists; this is a scroll-restoration
|
||||
// convenience, not a persistent store.
|
||||
const MAX_ENTRIES = 20;
|
||||
const states = new Map<string, ListState>();
|
||||
|
||||
export function saveListState(key: string, state: ListState): void {
|
||||
// Re-insert to mark as most-recently-used.
|
||||
states.delete(key);
|
||||
states.set(key, state);
|
||||
if (states.size > MAX_ENTRIES) {
|
||||
const oldest = states.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
states.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
+27
-2
@@ -17,6 +17,16 @@ export const activeUnloadedRouteStore = writable<Route>(InitialStore);
|
||||
|
||||
let currentUrl: URL | undefined;
|
||||
|
||||
// Set while a back/forward (popstate) navigation is in flight, then frozen onto
|
||||
// `historyNavigation` for the route that becomes active, so views can choose to
|
||||
// restore prior scroll state only for history navigations.
|
||||
let pendingHistoryNavigation = false;
|
||||
let historyNavigation = false;
|
||||
|
||||
export function isHistoryNavigation(): boolean {
|
||||
return historyNavigation;
|
||||
}
|
||||
|
||||
export async function loadFromLocation(): Promise<void> {
|
||||
await navigateToUrl("replace", new URL(window.location.href));
|
||||
}
|
||||
@@ -36,6 +46,10 @@ async function navigateToUrl(
|
||||
currentUrl.pathname === pathname &&
|
||||
currentUrl.search === url.search
|
||||
) {
|
||||
// The navigation this flag was set for ends here; without the reset a
|
||||
// same-URL popstate would leak it onto the next unrelated navigation,
|
||||
// which would then wrongly restore cached list state.
|
||||
pendingHistoryNavigation = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,7 +65,10 @@ async function navigateToUrl(
|
||||
}
|
||||
}
|
||||
|
||||
const offPopstate = on(window, "popstate", () => loadFromLocation());
|
||||
const offPopstate = on(window, "popstate", () => {
|
||||
pendingHistoryNavigation = true;
|
||||
return loadFromLocation();
|
||||
});
|
||||
|
||||
const offNavigateAnchor = on(document, "click", e => {
|
||||
const [anchor] = e
|
||||
@@ -79,10 +96,17 @@ async function navigate(
|
||||
action: "push" | "replace",
|
||||
newRoute: Route,
|
||||
): Promise<void> {
|
||||
const historyNav = pendingHistoryNavigation;
|
||||
pendingHistoryNavigation = false;
|
||||
isLoading.set(true);
|
||||
const path = routeToPath(newRoute);
|
||||
|
||||
if (action === "push") {
|
||||
if (
|
||||
action === "push" &&
|
||||
path !== window.location.pathname + window.location.search
|
||||
) {
|
||||
// Pushing the route that is already active would mint a duplicate
|
||||
// history entry, making Back appear to do nothing.
|
||||
window.history.pushState(newRoute, "", path);
|
||||
} else if (action === "replace") {
|
||||
window.history.replaceState(newRoute, "");
|
||||
@@ -99,6 +123,7 @@ async function navigate(
|
||||
return;
|
||||
}
|
||||
|
||||
historyNavigation = historyNav;
|
||||
activeRouteStore.set(loadedRoute);
|
||||
activeUnloadedRouteStore.set(newRoute);
|
||||
isLoading.set(false);
|
||||
|
||||
@@ -16,6 +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 * as router from "@app/lib/router";
|
||||
@@ -41,12 +43,32 @@
|
||||
|
||||
const { repo, issues, status }: Props = $props();
|
||||
|
||||
function listKey(filter: IssueStatus): string {
|
||||
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 issues prop changes.
|
||||
// $effect resets pagination state when the filter changes.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items = $state(issues.content);
|
||||
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 more = $state(issues.more);
|
||||
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("");
|
||||
@@ -63,13 +85,36 @@
|
||||
const abort = async (): Promise<undefined> => undefined;
|
||||
|
||||
$effect(() => {
|
||||
items = issues.content;
|
||||
more = issues.more;
|
||||
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);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
updateIssueCounts(
|
||||
@@ -121,6 +166,7 @@
|
||||
|
||||
if (page !== undefined) {
|
||||
items = page.content;
|
||||
cursor = page.content.length;
|
||||
more = page.more;
|
||||
}
|
||||
|
||||
@@ -141,7 +187,7 @@
|
||||
return await invoke<PaginatedQuery<Issue[]>>("list_issues", {
|
||||
rid: repo.rid,
|
||||
status,
|
||||
skip: all ? 0 : items.length,
|
||||
skip: all ? 0 : cursor,
|
||||
take: all ? undefined : DEFAULT_TAKE,
|
||||
});
|
||||
});
|
||||
@@ -156,7 +202,17 @@
|
||||
}
|
||||
|
||||
more = page.more;
|
||||
items = all ? page.content : [...items, ...page.content];
|
||||
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) {
|
||||
@@ -315,33 +371,38 @@
|
||||
</div>
|
||||
</Topbar>
|
||||
|
||||
<ScrollArea style="height: 100%; min-width: 0;">
|
||||
<div bind:clientHeight={chromeHeight}>
|
||||
{#if issueCountMismatch(status)}
|
||||
<CobCacheWarning
|
||||
noun="issues"
|
||||
{cacheState}
|
||||
onRebuild={rebuildIssueCache} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchResults.length === 0}
|
||||
{#if searchResults.length === 0}
|
||||
{#if issueCountMismatch(status)}
|
||||
<CobCacheWarning
|
||||
noun="issues"
|
||||
{cacheState}
|
||||
onRebuild={rebuildIssueCache} />
|
||||
{/if}
|
||||
<div
|
||||
class="global-flex"
|
||||
style:flex="1"
|
||||
style:justify-content="center"
|
||||
style:align-items="center">
|
||||
<div
|
||||
class="global-flex"
|
||||
style:flex="1"
|
||||
style:justify-content="center"
|
||||
style:align-items="center">
|
||||
<div
|
||||
class="txt-missing txt-body-m-regular global-flex"
|
||||
style:gap="0.25rem">
|
||||
{#if items.length > 0}
|
||||
No matching issues
|
||||
{:else}
|
||||
No {status === "all" ? "" : status} issues
|
||||
{/if}
|
||||
</div>
|
||||
class="txt-missing txt-body-m-regular global-flex"
|
||||
style:gap="0.25rem">
|
||||
{#if items.length > 0}
|
||||
No matching issues
|
||||
{:else}
|
||||
No {status === "all" ? "" : status} issues
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ScrollArea style="height: 100%; min-width: 0;">
|
||||
<div bind:clientHeight={chromeHeight}>
|
||||
{#if issueCountMismatch(status)}
|
||||
<CobCacheWarning
|
||||
noun="issues"
|
||||
{cacheState}
|
||||
onRebuild={rebuildIssueCache} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<VirtualList
|
||||
items={searchResults}
|
||||
hasMore={more}
|
||||
@@ -349,7 +410,10 @@
|
||||
onLoadMore={() => loadMoreContent()}
|
||||
startMargin={chromeHeight}
|
||||
estimatedItemSize={80}
|
||||
getKey={result => result.obj.issue.id}>
|
||||
getKey={result => result.obj.issue.id}
|
||||
{initialCache}
|
||||
{initialScrollOffset}
|
||||
onState={persistScroll}>
|
||||
{#snippet row(result)}
|
||||
<div class="row">
|
||||
<IssueTeaser
|
||||
@@ -360,7 +424,7 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
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 {
|
||||
patchCountMismatch,
|
||||
@@ -39,12 +41,32 @@
|
||||
|
||||
const { repo, patches, status }: Props = $props();
|
||||
|
||||
function listKey(filter: PatchStatus | undefined): string {
|
||||
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 patches prop changes.
|
||||
// $effect resets pagination state when the filter changes.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items = $state(patches.content);
|
||||
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 more = $state(patches.more);
|
||||
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("");
|
||||
@@ -61,13 +83,36 @@
|
||||
const abort = async (): Promise<undefined> => undefined;
|
||||
|
||||
$effect(() => {
|
||||
items = patches.content;
|
||||
more = patches.more;
|
||||
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);
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -112,6 +157,7 @@
|
||||
|
||||
if (page !== undefined) {
|
||||
items = page.content;
|
||||
cursor = page.content.length;
|
||||
more = page.more;
|
||||
}
|
||||
|
||||
@@ -132,7 +178,7 @@
|
||||
return await invoke<PaginatedQuery<Patch[]>>("list_patches", {
|
||||
rid: repo.rid,
|
||||
status,
|
||||
skip: all ? 0 : items.length,
|
||||
skip: all ? 0 : cursor,
|
||||
take: all ? undefined : DEFAULT_TAKE,
|
||||
});
|
||||
});
|
||||
@@ -147,7 +193,17 @@
|
||||
}
|
||||
|
||||
more = page.more;
|
||||
items = all ? page.content : [...items, ...page.content];
|
||||
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) {
|
||||
@@ -343,33 +399,38 @@
|
||||
</div>
|
||||
</Topbar>
|
||||
|
||||
<ScrollArea style="height: 100%; min-width: 0;">
|
||||
<div bind:clientHeight={chromeHeight}>
|
||||
{#if patchCountMismatch(status)}
|
||||
<CobCacheWarning
|
||||
noun="patches"
|
||||
{cacheState}
|
||||
onRebuild={rebuildPatchCache} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchResults.length === 0}
|
||||
{#if searchResults.length === 0}
|
||||
{#if patchCountMismatch(status)}
|
||||
<CobCacheWarning
|
||||
noun="patches"
|
||||
{cacheState}
|
||||
onRebuild={rebuildPatchCache} />
|
||||
{/if}
|
||||
<div
|
||||
class="global-flex"
|
||||
style:flex="1"
|
||||
style:justify-content="center"
|
||||
style:align-items="center">
|
||||
<div
|
||||
class="global-flex"
|
||||
style:flex="1"
|
||||
style:justify-content="center"
|
||||
style:align-items="center">
|
||||
<div
|
||||
class="txt-missing txt-body-m-regular global-flex"
|
||||
style:gap="0.25rem">
|
||||
{#if items.length > 0}
|
||||
No matching patches
|
||||
{:else}
|
||||
No {status === undefined ? "" : status} patches
|
||||
{/if}
|
||||
</div>
|
||||
class="txt-missing txt-body-m-regular global-flex"
|
||||
style:gap="0.25rem">
|
||||
{#if items.length > 0}
|
||||
No matching patches
|
||||
{:else}
|
||||
No {status === undefined ? "" : status} patches
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ScrollArea style="height: 100%; min-width: 0;">
|
||||
<div bind:clientHeight={chromeHeight}>
|
||||
{#if patchCountMismatch(status)}
|
||||
<CobCacheWarning
|
||||
noun="patches"
|
||||
{cacheState}
|
||||
onRebuild={rebuildPatchCache} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<VirtualList
|
||||
items={searchResults}
|
||||
hasMore={more}
|
||||
@@ -377,7 +438,10 @@
|
||||
onLoadMore={() => loadMoreContent()}
|
||||
startMargin={chromeHeight}
|
||||
estimatedItemSize={80}
|
||||
getKey={result => result.obj.patch.id}>
|
||||
getKey={result => result.obj.patch.id}
|
||||
{initialCache}
|
||||
{initialScrollOffset}
|
||||
onState={persistScroll}>
|
||||
{#snippet row(result)}
|
||||
<div class="row">
|
||||
<PatchTeaser
|
||||
@@ -388,7 +452,7 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
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 * as router from "@app/lib/router";
|
||||
import { modifierKey } from "@app/lib/utils";
|
||||
@@ -44,12 +46,25 @@
|
||||
commits: Commit[];
|
||||
};
|
||||
|
||||
function listKey(): string {
|
||||
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 commits prop changes.
|
||||
// pagination state when the peer/revision changes.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items = $state(commits.content);
|
||||
let items = $state(restored?.items ?? commits.content);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let more = $state(commits.more);
|
||||
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("");
|
||||
@@ -60,13 +75,35 @@
|
||||
const abort = async (): Promise<undefined> => undefined;
|
||||
|
||||
$effect(() => {
|
||||
items = commits.content;
|
||||
more = commits.more;
|
||||
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);
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
@@ -295,7 +332,10 @@
|
||||
{loadingMore}
|
||||
onLoadMore={() => loadMoreContent()}
|
||||
getKey={r =>
|
||||
r.type === "header" ? `h:${r.key}` : `c:${r.commit.id}`}>
|
||||
r.type === "header" ? `h:${r.key}` : `c:${r.commit.id}`}
|
||||
{initialCache}
|
||||
{initialScrollOffset}
|
||||
onState={persistScroll}>
|
||||
{#snippet row(item)}
|
||||
{#if item.type === "header"}
|
||||
<div class="group-title">{item.label}</div>
|
||||
|
||||
Reference in New Issue
Block a user