diff --git a/web/src/lib/api/issue.ts b/web/src/lib/api/issue.ts index 703057dbc..791150bb1 100644 --- a/web/src/lib/api/issue.ts +++ b/web/src/lib/api/issue.ts @@ -45,6 +45,7 @@ export interface IssueListItem { export interface IssueListPage { cursor?: string | null; + total?: number; items: IssueListItem[]; } diff --git a/web/src/lib/api/records.ts b/web/src/lib/api/records.ts index c958ab7b4..57858662b 100644 --- a/web/src/lib/api/records.ts +++ b/web/src/lib/api/records.ts @@ -85,6 +85,7 @@ export interface PullListItem { export interface PullListPage { cursor?: string | null; + total?: number; items: PullListItem[]; } @@ -121,12 +122,13 @@ export const getPulls = (ctx: BobbinContext, pulls: readonly string[], init?: Xr export interface StarListPage { cursor?: string | null; + total?: number; items: RecordView[]; } export const listStars = ( ctx: BobbinContext, subject: string, - filter: { limit?: number; cursor?: string; order?: "asc" | "desc" } = {}, + filter: { limit?: number; cursor?: string; offset?: number; order?: "asc" | "desc" } = {}, init?: XrpcRequestInit ) => jsonGet(ctx, "sh.tangled.feed.listStars", { subject, ...filter }, init); diff --git a/web/src/lib/components/profile/pagination.svelte.ts b/web/src/lib/components/profile/pagination.svelte.ts index a22dfe3f3..ce8e8d313 100644 --- a/web/src/lib/components/profile/pagination.svelte.ts +++ b/web/src/lib/components/profile/pagination.svelte.ts @@ -11,11 +11,7 @@ export interface OffsetPager { select: (target: number) => Promise; } -export const pageCount = (total: number, limit: number): number => - Math.max(1, Math.ceil(Math.max(total, 0) / limit)); - -export const discoveredPageCount = (page: number, hasNext: boolean): number => - Math.max(1, page + (hasNext ? 1 : 0)); +export { discoveredPageCount, pageCount } from "$lib/pagination"; // size the last page to its remainder, or a full page when total is unknown export const pageRows = (page: number, limit: number, total?: number): number => diff --git a/web/src/lib/components/ui/RoutePagination.svelte b/web/src/lib/components/ui/RoutePagination.svelte new file mode 100644 index 000000000..7c7367855 --- /dev/null +++ b/web/src/lib/components/ui/RoutePagination.svelte @@ -0,0 +1,29 @@ + + + + {@const count = await pageCount} + {#if count > 1} +
+ +
+ {/if} +
diff --git a/web/src/lib/pagination.svelte.ts b/web/src/lib/pagination.svelte.ts new file mode 100644 index 000000000..b50214da4 --- /dev/null +++ b/web/src/lib/pagination.svelte.ts @@ -0,0 +1,12 @@ +import { goto } from "$app/navigation"; +import { page } from "$app/state"; + +export const gotoPage = (nextPage: number) => { + const url = new URL(page.url); + if (nextPage > 1) { + url.searchParams.set("page", String(nextPage)); + } else { + url.searchParams.delete("page"); + } + void goto(url.pathname + url.search); +}; diff --git a/web/src/lib/pagination.test.ts b/web/src/lib/pagination.test.ts new file mode 100644 index 000000000..6d60caa03 --- /dev/null +++ b/web/src/lib/pagination.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { discoveredPageCount, offsetFor, pageCount, pageParam } from "./pagination"; + +describe("pagination helpers", () => { + describe("pageParam", () => { + it("defaults to 1 when missing", () => { + expect(pageParam(new URL("https://tangled.org/repo"))).toBe(1); + }); + + it("parses valid positive page numbers", () => { + expect(pageParam(new URL("https://tangled.org/repo?page=3"))).toBe(3); + }); + + it("falls back to 1 for invalid or zero pages", () => { + expect(pageParam(new URL("https://tangled.org/repo?page=0"))).toBe(1); + expect(pageParam(new URL("https://tangled.org/repo?page=-5"))).toBe(1); + expect(pageParam(new URL("https://tangled.org/repo?page=abc"))).toBe(1); + }); + }); + + describe("offsetFor", () => { + it("returns undefined for page 1", () => { + expect(offsetFor(1, 30)).toBeUndefined(); + }); + + it("returns correct offset for pages > 1", () => { + expect(offsetFor(2, 30)).toBe(30); + expect(offsetFor(3, 30)).toBe(60); + }); + }); + + describe("pageCount", () => { + it("returns 1 for 0 items", () => { + expect(pageCount(0, 30)).toBe(1); + }); + + it("calculates ceiling pages correctly", () => { + expect(pageCount(30, 30)).toBe(1); + expect(pageCount(31, 30)).toBe(2); + expect(pageCount(90, 30)).toBe(3); + }); + }); + + describe("discoveredPageCount", () => { + it("increments page when hasNext is true", () => { + expect(discoveredPageCount(1, true)).toBe(2); + expect(discoveredPageCount(5, true)).toBe(6); + }); + + it("stays on current page when hasNext is false", () => { + expect(discoveredPageCount(1, false)).toBe(1); + expect(discoveredPageCount(5, false)).toBe(5); + }); + }); +}); diff --git a/web/src/lib/pagination.ts b/web/src/lib/pagination.ts new file mode 100644 index 000000000..879400c2c --- /dev/null +++ b/web/src/lib/pagination.ts @@ -0,0 +1,14 @@ +export const pageParam = (url: URL): number => { + const raw = url.searchParams.get("page") ?? ""; + const parsed = /^\d+$/.test(raw) ? Number(raw) : 1; + return parsed >= 1 ? parsed : 1; +}; + +export const offsetFor = (page: number, limit: number): number | undefined => + page > 1 ? (page - 1) * limit : undefined; + +export const pageCount = (total: number, limit: number): number => + Math.max(1, Math.ceil(Math.max(total, 0) / limit)); + +export const discoveredPageCount = (page: number, hasNext: boolean): number => + Math.max(1, page + (hasNext ? 1 : 0)); diff --git a/web/src/routes/[handle]/[repo]/branches/+page.svelte b/web/src/routes/[handle]/[repo]/branches/+page.svelte index 61c4e4ac0..c536a38ca 100644 --- a/web/src/routes/[handle]/[repo]/branches/+page.svelte +++ b/web/src/routes/[handle]/[repo]/branches/+page.svelte @@ -1,23 +1,11 @@ @@ -32,17 +20,4 @@ /> - - {@const pageCount = await data.pageCount} - {#if pageCount > 1} -
- -
- {/if} -
+ diff --git a/web/src/routes/[handle]/[repo]/branches/+page.ts b/web/src/routes/[handle]/[repo]/branches/+page.ts index a2732d04d..db2b043a3 100644 --- a/web/src/routes/[handle]/[repo]/branches/+page.ts +++ b/web/src/routes/[handle]/[repo]/branches/+page.ts @@ -2,28 +2,30 @@ import { branches, gitTarget } from "$lib/api/gitclient"; import { REF_LIMIT } from "$lib/api/repoIndex"; import { sortBranches, toBranchSummary } from "$lib/api/repo"; import { stream } from "$lib/api/load"; +import { discoveredPageCount, offsetFor, pageCount, pageParam } from "$lib/pagination"; import type { PageLoad } from "./$types"; export const load: PageLoad = async (event) => { const parent = await event.parent(); const repoPromise = Promise.resolve(parent.repo); - const rawPage = event.url.searchParams.get("page") ?? ""; - const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; - const page = parsed >= 1 ? parsed : 1; - const cursor = page > 1 ? String((page - 1) * REF_LIMIT) : undefined; + const page = pageParam(event.url); + const offset = offsetFor(page, REF_LIMIT); + const cursor = offset !== undefined ? String(offset) : undefined; const results = repoPromise.then((repo) => { const git = gitTarget(parent.publicConfig, repo, event.fetch); return branches(git, REF_LIMIT, cursor); }); const list = results.then((res) => sortBranches((res.branches ?? []).map(toBranchSummary))); - const pageCount = results.then((res) => { + const pages = results.then((res) => { const total = res.total ?? 0; const len = res.branches?.length ?? 0; - return total > 0 ? Math.ceil(total / REF_LIMIT) : page + (len === REF_LIMIT ? 1 : 0); + return total > 0 + ? pageCount(total, REF_LIMIT) + : discoveredPageCount(page, len === REF_LIMIT); }); return { branches: stream(list), page, - pageCount: stream(pageCount) + pageCount: stream(pages) }; }; diff --git a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte index 514725787..091067f7d 100644 --- a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte @@ -1,24 +1,13 @@ @@ -33,6 +21,6 @@ commits={await data.commits} page={data.page} pageCount={await data.pageCount} - onPageChange={changePage} + onPageChange={gotoPage} /> diff --git a/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.ts b/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.ts index 4c2df3565..719569630 100644 --- a/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.ts +++ b/web/src/routes/[handle]/[repo]/history/[ref]/[...path]/+page.ts @@ -4,6 +4,7 @@ import { authorsOf, mockFileHistory } from "$lib/api/fileHistory"; import { stream } from "$lib/api/load"; import { withAuthorHandles } from "$lib/api/repoIndex"; import { toCommitSummary } from "$lib/api/repo"; +import { pageCount, pageParam } from "$lib/pagination"; import type { PageLoad } from "./$types"; const COMMIT_LIMIT = 50; @@ -16,9 +17,7 @@ export const load: PageLoad = async (event) => { const ref = event.params.ref; const path = event.params.path; - const rawPage = event.url.searchParams.get("page") ?? ""; - const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; - const page = parsed >= 1 ? parsed : 1; + const page = pageParam(event.url); // MOCK: the knot ignores log's `path`, so the history is invented from the // repo's own authors. see $lib/api/fileHistory @@ -36,10 +35,8 @@ export const load: PageLoad = async (event) => { return mockFileHistory(repo.uri, path, authorsOf(sampled)); }); - const pageCount = historyPromise.then((history) => - Math.max(Math.ceil(history.length / COMMIT_LIMIT), 1) - ); - const commits = Promise.all([historyPromise, pageCount]).then(([history, count]) => { + const pages = historyPromise.then((history) => pageCount(history.length, COMMIT_LIMIT)); + const commits = Promise.all([historyPromise, pages]).then(([history, count]) => { const shown = Math.min(page, count); return history.slice((shown - 1) * COMMIT_LIMIT, shown * COMMIT_LIMIT); }); @@ -49,6 +46,6 @@ export const load: PageLoad = async (event) => { path, page, commits: stream(commits), - pageCount: stream(pageCount) + pageCount: stream(pages) }; }; diff --git a/web/src/routes/[handle]/[repo]/issues/+page.svelte b/web/src/routes/[handle]/[repo]/issues/+page.svelte index 044b48784..f8fae77f2 100644 --- a/web/src/routes/[handle]/[repo]/issues/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/+page.svelte @@ -1,21 +1,22 @@ {#snippet skeleton()} - +
- -
+
+
{/snippet} @@ -23,14 +24,13 @@ {@const repo = await data.repo} -
{#snippet skeleton()} @@ -47,4 +47,6 @@ />
+ + diff --git a/web/src/routes/[handle]/[repo]/issues/+page.ts b/web/src/routes/[handle]/[repo]/issues/+page.ts index eacc1e895..1c3f26ff0 100644 --- a/web/src/routes/[handle]/[repo]/issues/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/+page.ts @@ -7,47 +7,51 @@ import type { IssueListPage } from "$lib/api/issue"; import { stream } from "$lib/api/load"; import { rkeyFromUri } from "$lib/api/uri"; import type { IssueSummary } from "$lib/components/repo/types"; +import { offsetFor, pageCount, pageParam } from "$lib/pagination"; import type { PageLoad } from "./$types"; +const ISSUE_LIMIT = 30; + export const load: PageLoad = async (event) => { const parent = await event.parent(); const repo = Promise.resolve(parent.repo); const state = event.url.searchParams.get("state") === "closed" ? "closed" : "open"; - const cursor = event.url.searchParams.get("cursor"); + const page = pageParam(event.url); + const offset = offsetFor(page, ISSUE_LIMIT); const viewerDid = parent.auth?.did; const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - // returned unawaited so the page can paint its skeleton, ssr still waits for them - const issues = repo.then(async (r) => { + const issuesResult = repo.then(async (r) => { const repoDid = r.repoDid; - if (!repoDid) return [] as IssueSummary[]; - return cached(`issues:${viewerDid}:${repoDid}:${state}:${cursor ?? ""}`, 30_000, () => + if (!repoDid) return null; + return cached(`issues:${viewerDid}:${repoDid}:${state}:${page}`, 30_000, () => enrich(ctx, { xrpc: "sh.tangled.repo.listIssues", - params: { subject: repoDid, state }, + params: { subject: repoDid, state, limit: ISSUE_LIMIT, offset }, enrich: [target(ISSUE_AUTHOR_DOCS, ["items[].uri"])] - }).then((page) => - page.output.items.map((item): IssueSummary => { - const author = authorOf(page.data, item.uri, ISSUE_AUTHOR_DOCS); - return { - uri: item.uri, - rkey: rkeyFromUri(item.uri), - title: item.value.title, - state: item.state === "closed" ? "closed" : "open", - authorDid: author.did, - authorHandle: author.handle, - createdAt: item.value.createdAt, - commentCount: item.commentCount - }; - }) - ) + }) ); }); - // the layout only knows the open count, the closed tab needs its own + const issues = issuesResult.then((result) => + (result?.output.items ?? []).map((item): IssueSummary => { + const author = authorOf(result!.data, item.uri, ISSUE_AUTHOR_DOCS); + return { + uri: item.uri, + rkey: rkeyFromUri(item.uri), + title: item.value.title, + state: item.state === "closed" ? "closed" : "open", + authorDid: author.did, + authorHandle: author.handle, + createdAt: item.value.createdAt, + commentCount: item.commentCount + }; + }) + ); + const closedCount = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return 0; @@ -58,8 +62,19 @@ export const load: PageLoad = async (event) => { .catch(() => 0); }); + const countPromise = + state === "closed" ? closedCount : Promise.resolve(parent.counts).then((c) => c.issues); + const pages = issuesResult.then((res) => { + if (!res) return 1; + const total = res.output.total; + if (total != null) return pageCount(total, ISSUE_LIMIT); + return countPromise.then((c) => pageCount(c, ISSUE_LIMIT)); + }); + return { state: state as "open" | "closed", + page, + pageCount: stream(pages), issues: stream(issues), openCount: stream(Promise.resolve(parent.counts).then((c) => c.issues)), closedCount: stream(closedCount) diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.svelte b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte index 5d636937e..fe533d856 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte @@ -3,6 +3,7 @@ import PipelineToolbar from "$lib/components/repo/pipelines/PipelineToolbar.svelte"; import Pending from "$lib/components/ui/Pending.svelte"; import Bones from "$lib/components/ui/Bones.svelte"; + import RoutePagination from "$lib/components/ui/RoutePagination.svelte"; import repoPipelinesPipelinelistDefault from "$lib/bones/repo-pipelines-pipelinelist--default.bones.json"; import repoPipelinesPipelinetoolbarAll from "$lib/bones/repo-pipelines-pipelinetoolbar--all.bones.json"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; @@ -22,8 +23,7 @@ /> {/snippet} - + {@const repo = await data.repo}
@@ -60,6 +60,7 @@ filtered={await data.filtered} forkLabels={data.forkLabels} /> + {/if} diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.ts b/web/src/routes/[handle]/[repo]/pipelines/+page.ts index ce07497de..7ade2d49a 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/+page.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.ts @@ -4,14 +4,15 @@ import { resolveForkRepoLabels } from "$lib/api/repo"; import { stream } from "$lib/api/load"; import { matchesQuery, type PipelineFilter } from "$lib/components/repo/pipelines/pipeline"; import type { PipelineSummary } from "$lib/components/repo/types"; +import { pageCount, pageParam } from "$lib/pagination"; import type { Did } from "@atcute/lexicons"; import type { PageLoad } from "./$types"; -// the appview only offers these two, a manual run still shows up under "all" +// manual runs only surface under "all" const FILTERS: PipelineFilter[] = ["all", "push", "pull_request"]; -// same page size as the appview's pipelines tab const PIPELINE_LIMIT = 30; +const MAX_FETCH = 250; export const load: PageLoad = async (event) => { const parent = await event.parent(); @@ -19,6 +20,9 @@ export const load: PageLoad = async (event) => { const requested = event.url.searchParams.get("trigger") as PipelineFilter | null; const filter = requested && FILTERS.includes(requested) ? requested : "all"; const query = event.url.searchParams.get("q") ?? ""; + const page = pageParam(event.url); + // spindle queryPipelines has no offset and caps at 250, so fetch up to the requested page + const fetchLimit = Math.min(MAX_FETCH, page * PIPELINE_LIMIT); const queryResult = repo.then(async (repo) => { const { spindle, repoDid } = repo; @@ -26,12 +30,12 @@ export const load: PageLoad = async (event) => { const ctx = createSpindleClient(spindle, event.fetch); try { - const page = await queryPipelines(ctx, { + const spindlePage = await queryPipelines(ctx, { repo: repoDid as Did, kinds: filter === "all" ? undefined : [filter], - limit: PIPELINE_LIMIT + limit: fetchLimit }); - return { page, spindleError: null }; + return { page: spindlePage, spindleError: null }; } catch { return { page: null, spindleError: `Could not reach ${spindle}` }; } @@ -40,10 +44,10 @@ export const load: PageLoad = async (event) => { // the lexicon says pipelines is required, but the spindle sends null for an // empty result const pipelines = Promise.all([repo, queryResult]).then(([repo, result]) => { - const page = result.page; - if (!page || !repo.repoDid) return [] as PipelineSummary[]; + const resultPage = result.page; + if (!resultPage || !repo.repoDid) return [] as PipelineSummary[]; return ( - (page.pipelines ?? []) + (resultPage.pipelines ?? []) // a spindle answering for a different repo is misconfigured or lying .filter((pipeline) => pipeline.repo === repo.repoDid) .map((pipeline) => toPipelineSummary(pipeline, repo.repoDid!)) @@ -51,9 +55,12 @@ export const load: PageLoad = async (event) => { ); }); - // fork labels stream in behind the list, cards show a skeleton until bobbin - // answers. a failed resolve leaves the bare branch - const forkLabels = pipelines + const shown = pipelines.then((list) => { + const start = (page - 1) * PIPELINE_LIMIT; + return list.slice(start, start + PIPELINE_LIMIT); + }); + + const forkLabels = shown .then((list) => { const forkDids = [ ...new Set( @@ -78,11 +85,15 @@ export const load: PageLoad = async (event) => { // the spindle counts every run matching the tab, the query narrows here const total = Promise.all([queryResult, pipelines]).then(([result, list]) => { - const page = result.page; - if (!page) return 0; - return query === "" ? page.total : list.length; + const resultPage = result.page; + if (!resultPage) return 0; + return query === "" ? resultPage.total : list.length; }); + const pages = total.then((t) => + Math.min(Math.floor(MAX_FETCH / PIPELINE_LIMIT), pageCount(t, PIPELINE_LIMIT)) + ); + const filtered = queryResult.then(({ page }) => { if (!page) return filter !== "all" || query !== ""; return page.total > 0 || filter !== "all" || query !== ""; @@ -93,7 +104,9 @@ export const load: PageLoad = async (event) => { return { filter, query, - pipelines: stream(pipelines), + page, + pageCount: stream(pages), + pipelines: stream(shown), forkLabels: stream(forkLabels), total: stream(total), filtered: stream(filtered), diff --git a/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts b/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts index c922a1506..da7d1a95e 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts @@ -74,6 +74,8 @@ describe("pipelines load", () => { const { event, fetchMock } = makeEvent({ spindle: null }); const data = await runLoad(event); expect(fetchMock).not.toHaveBeenCalled(); + expect(data.page).toBe(1); + expect(await data.pageCount).toBe(1); expect(await data.pipelines).toEqual([]); expect(await data.total).toBe(0); expect(await data.filtered).toBe(false); @@ -84,6 +86,8 @@ describe("pipelines load", () => { fetchMock.mockResolvedValue(jsonResponse({ pipelines: [pushPipeline()], total: 1 })); const data = await runLoad(event); + expect(data.page).toBe(1); + expect(await data.pageCount).toBe(1); const pipelines = await data.pipelines; expect(pipelines).toHaveLength(1); expect(pipelines[0]).toMatchObject({ @@ -91,8 +95,6 @@ describe("pipelines load", () => { workflows: [{ name: "build.yml", status: "success", duration: 120_000 }] }); expect(await data.total).toBe(1); - // setup steps belong to a repo that never ran anything, with runs the - // flag just has to keep them hidden expect(await data.filtered).toBe(true); }); @@ -213,4 +215,48 @@ describe("pipelines load", () => { await expect(data.forkLabels).resolves.toEqual({}); expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("paginates pipelines across pages", async () => { + const { event, fetchMock } = makeEvent({ search: "?page=2" }); + const allPipelines = Array.from({ length: 35 }, (_, i) => + pushPipeline({ id: `pipe-${i}` }) + ); + fetchMock.mockResolvedValue( + jsonResponse({ + pipelines: allPipelines, + total: 35 + }) + ); + + const data = await runLoad(event); + expect(data.page).toBe(2); + expect(await data.pageCount).toBe(2); + + const url = new URL(fetchMock.mock.calls[0][0] as string); + expect(url.searchParams.get("limit")).toBe("60"); + + const pipelines = await data.pipelines; + expect(pipelines).toHaveLength(5); + expect(pipelines[0].id).toBe("pipe-30"); + expect(pipelines[4].id).toBe("pipe-34"); + }); + + it("falls back to page 1 for invalid page numbers", async () => { + const { event, fetchMock } = makeEvent({ search: "?page=0" }); + fetchMock.mockResolvedValue(jsonResponse({ pipelines: [pushPipeline()], total: 1 })); + + const data = await runLoad(event); + expect(data.page).toBe(1); + expect(await data.pageCount).toBe(1); + + const url = new URL(fetchMock.mock.calls[0][0] as string); + expect(url.searchParams.get("limit")).toBe("30"); + }); + + it("clamps pageCount to the 250-fetch ceiling", async () => { + const { event, fetchMock } = makeEvent({ search: "?page=1" }); + fetchMock.mockResolvedValue(jsonResponse({ pipelines: [pushPipeline()], total: 400 })); + + const data = await runLoad(event); + expect(await data.pageCount).toBe(8); + }); }); diff --git a/web/src/routes/[handle]/[repo]/pulls/+page.svelte b/web/src/routes/[handle]/[repo]/pulls/+page.svelte index e6dd71f5d..b0bf1445a 100644 --- a/web/src/routes/[handle]/[repo]/pulls/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/+page.svelte @@ -1,21 +1,22 @@ {#snippet skeleton()} - +
- -
+
+
{/snippet} @@ -23,27 +24,28 @@ {@const repo = await data.repo} -
{#snippet skeleton()} {/snippet}
+ + diff --git a/web/src/routes/[handle]/[repo]/pulls/+page.ts b/web/src/routes/[handle]/[repo]/pulls/+page.ts index 0c14df60a..1f3ce0bd7 100644 --- a/web/src/routes/[handle]/[repo]/pulls/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/+page.ts @@ -7,51 +7,52 @@ import type { PullListPage, PullState } from "$lib/api/records"; import { stream } from "$lib/api/load"; import { rkeyFromUri } from "$lib/api/uri"; import type { PullSummary } from "$lib/components/repo/types"; +import { offsetFor, pageCount, pageParam } from "$lib/pagination"; import type { PageLoad } from "./$types"; +const PULL_LIMIT = 30; + export const load: PageLoad = async (event) => { const parent = await event.parent(); const repo = Promise.resolve(parent.repo); const raw = event.url.searchParams.get("state"); const state: PullState = raw === "closed" || raw === "merged" ? raw : "open"; - const cursor = event.url.searchParams.get("cursor"); + const page = pageParam(event.url); + const offset = offsetFor(page, PULL_LIMIT); const viewerDid = parent.auth?.did; const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - // returned unawaited so the page can paint its skeleton, ssr still waits for them - const pulls = repo.then(async (r) => { + const pullsResult = repo.then(async (r) => { const repoDid = r.repoDid; - if (!repoDid) return [] as PullSummary[]; - return cached(`pulls:${viewerDid}:${repoDid}:${state}:${cursor ?? ""}`, 30_000, () => + if (!repoDid) return null; + return cached(`pulls:${viewerDid}:${repoDid}:${state}:${page}`, 30_000, () => enrich(ctx, { xrpc: "sh.tangled.repo.listPulls", - params: { subject: repoDid, status: state }, + params: { subject: repoDid, status: state, limit: PULL_LIMIT, offset }, enrich: [target(PULL_AUTHOR_DOCS, ["items[].uri"])] - }).then((page) => - page.output.items.map((item): PullSummary => { - const author = authorOf(page.data, item.uri, PULL_AUTHOR_DOCS); - return { - uri: item.uri, - rkey: rkeyFromUri(item.uri), - title: item.value.title, - state: - item.state === "closed" || item.state === "merged" - ? item.state - : "open", - authorDid: author.did, - authorHandle: author.handle, - createdAt: item.value.createdAt, - commentCount: item.commentCount - }; - }) - ) + }) ); }); - // the layout only knows the open count, the closed tab needs its own + const pulls = pullsResult.then((result) => + (result?.output.items ?? []).map((item): PullSummary => { + const author = authorOf(result!.data, item.uri, PULL_AUTHOR_DOCS); + return { + uri: item.uri, + rkey: rkeyFromUri(item.uri), + title: item.value.title, + state: item.state === "closed" || item.state === "merged" ? item.state : "open", + authorDid: author.did, + authorHandle: author.handle, + createdAt: item.value.createdAt, + commentCount: item.commentCount + }; + }) + ); + const closedCount = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return 0; @@ -72,8 +73,23 @@ export const load: PageLoad = async (event) => { .catch(() => 0); }); + const countPromise = + state === "open" + ? Promise.resolve(parent.counts).then((c) => c.pulls) + : state === "merged" + ? mergedCount + : closedCount; + const pages = pullsResult.then((res) => { + if (!res) return 1; + const total = res.output.total; + if (total != null) return pageCount(total, PULL_LIMIT); + return countPromise.then((c) => pageCount(c, PULL_LIMIT)); + }); + return { state, + page, + pageCount: stream(pages), pulls: stream(pulls), openCount: stream(Promise.resolve(parent.counts).then((c) => c.pulls)), closedCount: stream(closedCount), diff --git a/web/src/routes/[handle]/[repo]/stars/+page.svelte b/web/src/routes/[handle]/[repo]/stars/+page.svelte index f6c945d72..4c9aeb30e 100644 --- a/web/src/routes/[handle]/[repo]/stars/+page.svelte +++ b/web/src/routes/[handle]/[repo]/stars/+page.svelte @@ -3,6 +3,7 @@ import Bones from "$lib/components/ui/Bones.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte"; import Pending from "$lib/components/ui/Pending.svelte"; + import RoutePagination from "$lib/components/ui/RoutePagination.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; import bonesStarredBySkeletonFixture from "$lib/bones/bones-starredby--skeleton-fixture.bones.json"; @@ -10,25 +11,23 @@ -

Starred by

- {#snippet skeleton()} - +
+ +
{/snippet} {@const starrers = await data.starrers} {#if starrers.length === 0} - + {:else} -
+
{#each starrers as starrer (starrer.did)} - + {/each}
{/if} + + diff --git a/web/src/routes/[handle]/[repo]/stars/+page.ts b/web/src/routes/[handle]/[repo]/stars/+page.ts index cbebae7c2..0250124d9 100644 --- a/web/src/routes/[handle]/[repo]/stars/+page.ts +++ b/web/src/routes/[handle]/[repo]/stars/+page.ts @@ -3,6 +3,7 @@ import { IdentityCache } from "$lib/api/identity"; import { stream } from "$lib/api/load"; import { listStars } from "$lib/api/records"; import { didFromUri } from "$lib/api/uri"; +import { offsetFor, pageCount, pageParam } from "$lib/pagination"; import type { PageLoad } from "./$types"; const LIMIT = 50; @@ -16,24 +17,25 @@ export interface Starrer { export const load: PageLoad = async (event) => { const parent = await event.parent(); const repo = Promise.resolve(parent.repo); + const page = pageParam(event.url); + const offset = offsetFor(page, LIMIT); const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - const starrers = repo.then(async (repo) => { + const resultPromise = repo.then(async (repo) => { const repoDid = repo.repoDid; + if (!repoDid) return null; - // no repoDid means bobbin never indexed this repo, so there is nothing to list - if (!repoDid) { - return [] as Starrer[]; - } + return listStars(ctx, repoDid, { limit: LIMIT, offset }).catch(() => null); + }); - // TODO: paginate - const result = await listStars(ctx, repoDid, { limit: LIMIT }).catch(() => null); - const items = result?.items ?? []; + const pages = resultPromise.then((res) => pageCount(res?.total ?? 0, LIMIT)); + const starrers = resultPromise.then(async (result) => { + const items = result?.items ?? []; const identity = new IdentityCache(ctx); return Promise.all( items.map(async (item): Promise => { @@ -44,5 +46,9 @@ export const load: PageLoad = async (event) => { ); }); - return { starrers: stream(starrers) }; + return { + page, + pageCount: stream(pages), + starrers: stream(starrers) + }; }; diff --git a/web/src/routes/[handle]/[repo]/tags/+page.svelte b/web/src/routes/[handle]/[repo]/tags/+page.svelte index 46de90174..c5aac5ad9 100644 --- a/web/src/routes/[handle]/[repo]/tags/+page.svelte +++ b/web/src/routes/[handle]/[repo]/tags/+page.svelte @@ -1,56 +1,25 @@ - -

Tags

- - {#snippet skeleton()} - - {/snippet} - {@const repo = await data.repo} -
- {#each await data.tags as tag (tag.name)} - - {:else} -

- This repository does not contain any tags. -

- {/each} -
-
-
- - {@const pageCount = await data.pageCount} - {#if pageCount > 1} -
- -
- {/if} + {#snippet skeleton()} + + {/snippet} + {@const repo = await data.repo} + {@const tags = await data.tags} + + {#each tags as tag (tag.name)} + + {/each} +
+ + diff --git a/web/src/routes/[handle]/[repo]/tags/+page.ts b/web/src/routes/[handle]/[repo]/tags/+page.ts index f450cc349..b0143b6fd 100644 --- a/web/src/routes/[handle]/[repo]/tags/+page.ts +++ b/web/src/routes/[handle]/[repo]/tags/+page.ts @@ -2,28 +2,30 @@ import { gitTarget, tags } from "$lib/api/gitclient"; import { REF_LIMIT } from "$lib/api/repoIndex"; import { toTagSummary } from "$lib/api/repo"; import { stream } from "$lib/api/load"; +import { discoveredPageCount, offsetFor, pageCount, pageParam } from "$lib/pagination"; import type { PageLoad } from "./$types"; export const load: PageLoad = async (event) => { const parent = await event.parent(); const repoPromise = Promise.resolve(parent.repo); - const rawPage = event.url.searchParams.get("page") ?? ""; - const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; - const page = parsed >= 1 ? parsed : 1; - const cursor = page > 1 ? String((page - 1) * REF_LIMIT) : undefined; + const page = pageParam(event.url); + const offset = offsetFor(page, REF_LIMIT); + const cursor = offset !== undefined ? String(offset) : undefined; const results = repoPromise.then((repo) => { const git = gitTarget(parent.publicConfig, repo, event.fetch); return tags(git, REF_LIMIT, cursor); }); const list = results.then((res) => (res.tags ?? []).map(toTagSummary)); - const pageCount = results.then((res) => { + const pages = results.then((res) => { const total = res.total ?? 0; const len = res.tags?.length ?? 0; - return total > 0 ? Math.ceil(total / REF_LIMIT) : page + (len === REF_LIMIT ? 1 : 0); + return total > 0 + ? pageCount(total, REF_LIMIT) + : discoveredPageCount(page, len === REF_LIMIT); }); return { tags: stream(list), page, - pageCount: stream(pageCount) + pageCount: stream(pages) }; };