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;
+ });