From 2bc474b9ced3fc7b850a0cde4d1cc869d51a2bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Wed, 17 Jun 2026 16:05:22 +0200 Subject: [PATCH] Add VirtualList component backed by virtua A reusable windowing wrapper that mounts virtua's Virtualizer against the existing OverlayScrollbars viewport, so the app-wide custom scrollbar is preserved. --- package-lock.json | 31 ++++ package.json | 1 + src/components/InfiniteScrollSentinel.svelte | 44 ----- src/components/VirtualList.svelte | 76 +++++++++ src/views/repo/Issues.svelte | 89 +++++----- src/views/repo/Patches.svelte | 89 +++++----- src/views/repo/RepoCommits.svelte | 167 +++++++++++-------- 7 files changed, 298 insertions(+), 199 deletions(-) delete mode 100644 src/components/InfiniteScrollSentinel.svelte create mode 100644 src/components/VirtualList.svelte diff --git a/package-lock.json b/package-lock.json index 5c606ab4d..6272f006d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "overlayscrollbars": "^2.16.0", "overlayscrollbars-svelte": "^0.5.5", "semver": "^7.8.0", + "virtua": "^0.49.1", "zod": "^4.4.3" }, "devDependencies": { @@ -4876,6 +4877,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/virtua": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.49.1.tgz", + "integrity": "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0", + "solid-js": ">=1.0", + "svelte": ">=5.0", + "vue": ">=3.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/vite": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", diff --git a/package.json b/package.json index 2c7210d45..d2cae3a93 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "overlayscrollbars": "^2.16.0", "overlayscrollbars-svelte": "^0.5.5", "semver": "^7.8.0", + "virtua": "^0.49.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/components/InfiniteScrollSentinel.svelte b/src/components/InfiniteScrollSentinel.svelte deleted file mode 100644 index 1e94def69..000000000 --- a/src/components/InfiniteScrollSentinel.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/src/components/VirtualList.svelte b/src/components/VirtualList.svelte new file mode 100644 index 000000000..10cd2bd3f --- /dev/null +++ b/src/components/VirtualList.svelte @@ -0,0 +1,76 @@ + + +{#if viewport} + + {#snippet children(item: T, index: number)} + {@render row(item, index)} + {/snippet} + +{/if} diff --git a/src/views/repo/Issues.svelte b/src/views/repo/Issues.svelte index 68fa90203..257db5250 100644 --- a/src/views/repo/Issues.svelte +++ b/src/views/repo/Issues.svelte @@ -25,10 +25,10 @@ import CobCacheWarning from "@app/components/CobCacheWarning.svelte"; import FuzzySearch from "@app/components/FuzzySearch.svelte"; import Icon from "@app/components/Icon.svelte"; - import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte"; import IssueTeaser from "@app/components/IssueTeaser.svelte"; import ScrollArea from "@app/components/ScrollArea.svelte"; import Topbar from "@app/components/Topbar.svelte"; + import VirtualList from "@app/components/VirtualList.svelte"; import CreateIssueModal from "@app/modals/CreateIssue.svelte"; import Layout from "./Layout.svelte"; @@ -53,6 +53,9 @@ let debouncedSearch = $state(""); let showSearch = $state(false); let cacheState: CacheEvent | undefined = $state(); + // Height of the cache-warning banner above the list; fed to the virtualizer + // as a start offset since it shares the same scroll viewport. + let chromeHeight = $state(0); const project = $derived(repo.payloads["xyz.radicle.project"]!); @@ -226,11 +229,8 @@ .filter .global-counter-badge { margin-left: 0.25rem; } - .list { - display: flex; - flex-direction: column; - gap: 1px; - min-height: 100%; + .row { + border-bottom: 1px solid var(--color-border-subtle); } @@ -316,44 +316,51 @@ - {#if issueCountMismatch(status)} - - {/if} - -
- {#each searchResults as result} - - {/each} - - {#if searchResults.length === 0} -
-
- {#if items.length > 0 && searchResults.length === 0} - No matching issues - {:else} - No {status === "all" ? "" : status} issues - {/if} -
-
+
+ {#if issueCountMismatch(status)} + {/if}
- + {#if searchResults.length === 0} +
+
+ {#if items.length > 0} + No matching issues + {:else} + No {status === "all" ? "" : status} issues + {/if} +
+
+ {:else} + loadMoreContent()} + startMargin={chromeHeight} + estimatedItemSize={80} + getKey={result => result.obj.issue.id}> + {#snippet row(result)} +
+ +
+ {/snippet} +
+ {/if}
diff --git a/src/views/repo/Patches.svelte b/src/views/repo/Patches.svelte index f56db4ed7..86c51480d 100644 --- a/src/views/repo/Patches.svelte +++ b/src/views/repo/Patches.svelte @@ -23,11 +23,11 @@ import CobCacheWarning from "@app/components/CobCacheWarning.svelte"; import FuzzySearch from "@app/components/FuzzySearch.svelte"; import Icon from "@app/components/Icon.svelte"; - import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte"; import NewPatchButton from "@app/components/NewPatchButton.svelte"; import PatchTeaser from "@app/components/PatchTeaser.svelte"; import ScrollArea from "@app/components/ScrollArea.svelte"; import Topbar from "@app/components/Topbar.svelte"; + import VirtualList from "@app/components/VirtualList.svelte"; import Layout from "./Layout.svelte"; @@ -51,6 +51,9 @@ let debouncedSearch = $state(""); let showSearch = $state(false); let cacheState: CacheEvent | undefined = $state(); + // Height of the cache-warning banner above the list; fed to the virtualizer + // as a start offset since it shares the same scroll viewport. + let chromeHeight = $state(0); const project = $derived(repo.payloads["xyz.radicle.project"]!); @@ -228,11 +231,8 @@ display: inline; } } - .list { - display: flex; - flex-direction: column; - gap: 1px; - min-height: 100%; + .row { + border-bottom: 1px solid var(--color-border-subtle); } @@ -344,44 +344,51 @@ - {#if patchCountMismatch(status)} - - {/if} - -
- {#each searchResults as result} - - {/each} - - {#if searchResults.length === 0} -
-
- {#if items.length > 0 && searchResults.length === 0} - No matching patches - {:else} - No {status === undefined ? "" : status} patches - {/if} -
-
+
+ {#if patchCountMismatch(status)} + {/if}
- + {#if searchResults.length === 0} +
+
+ {#if items.length > 0} + No matching patches + {:else} + No {status === undefined ? "" : status} patches + {/if} +
+
+ {:else} + loadMoreContent()} + startMargin={chromeHeight} + estimatedItemSize={80} + getKey={result => result.obj.patch.id}> + {#snippet row(result)} +
+ +
+ {/snippet} +
+ {/if}
diff --git a/src/views/repo/RepoCommits.svelte b/src/views/repo/RepoCommits.svelte index a4566b04e..fffa7d14d 100644 --- a/src/views/repo/RepoCommits.svelte +++ b/src/views/repo/RepoCommits.svelte @@ -13,10 +13,10 @@ import CobCommitTeaser from "@app/components/CobCommitTeaser.svelte"; import FuzzySearch from "@app/components/FuzzySearch.svelte"; - import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte"; import RepoHeader from "@app/components/RepoHeader.svelte"; import ScrollArea from "@app/components/ScrollArea.svelte"; import SourceHeader from "@app/components/SourceHeader.svelte"; + import VirtualList from "@app/components/VirtualList.svelte"; import Layout from "./Layout.svelte"; @@ -172,6 +172,29 @@ return Object.values(groups); }); + + type CommitRow = + | { type: "header"; key: string; label: string } + | { type: "commit"; commit: Commit; first: boolean; last: boolean }; + + // Flatten the day groups into a single row stream so the list can be + // virtualized; the row snippet renders headers and commits differently. + // `first`/`last` mark a group's boundaries so its commits form a bordered card. + const commitRows = $derived.by(() => { + const rows: CommitRow[] = []; + for (const group of groupedCommits) { + rows.push({ type: "header", key: group.key, label: group.label }); + group.commits.forEach((commit, i) => { + rows.push({ + type: "commit", + commit, + first: i === 0, + last: i === group.commits.length - 1, + }); + }); + } + return rows; + });