diff --git a/web/svelte.config.js b/web/svelte.config.js --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -7,7 +7,10 @@ const config = { preprocess: vitePreprocess(), compilerOptions: { - runes: ({ filename }) => (filename.split(/[/\\]/).includes("node_modules") ? undefined : true) + runes: ({ filename }) => (filename.split(/[/\\]/).includes("node_modules") ? undefined : true), + // `await` in templates, so a page can hand a streamed promise straight to + // instead of threading {#await} through every view + experimental: { async: true } }, kit: { adapter: useCloudflareAdapter ? cloudflareAdapter({ config: "wrangler.dev.jsonc" }) : adapter(), diff --git a/web/src/app.css b/web/src/app.css --- a/web/src/app.css +++ b/web/src/app.css @@ -323,6 +323,26 @@ } } +@utility row-breathe { + animation: row-breathe 1.2s ease-in-out infinite; + background-color: var(--color-background-muted); + border-radius: var(--radius-sm, 0.25rem); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes row-breathe { + 0%, + 100% { + opacity: 0.88; + } + 50% { + opacity: 1; + } +} + @layer base { @font-face { font-family: "InterVariable"; diff --git a/web/src/lib/navPulse.test.ts b/web/src/lib/navPulse.test.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/navPulse.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { navigatingTo } from "./navPulse"; + +const mockNavigating = vi.hoisted(() => ({ + to: null as { url: URL } | null +})); + +vi.mock("$app/state", () => ({ + navigating: mockNavigating +})); + +describe("navigatingTo", () => { + it("returns false when there is no active navigation", () => { + mockNavigating.to = null; + expect(navigatingTo("/alice/repo")).toBe(false); + }); + + it("returns true when navigating to the matching href path", () => { + mockNavigating.to = { url: new URL("https://tangled.org/alice/repo") }; + expect(navigatingTo("/alice/repo")).toBe(true); + expect(navigatingTo("https://tangled.org/alice/repo")).toBe(true); + }); + + it("returns false when navigating to a different path", () => { + mockNavigating.to = { url: new URL("https://tangled.org/alice/repo/tree/main") }; + expect(navigatingTo("/alice/repo")).toBe(false); + expect(navigatingTo("/alice/other-repo")).toBe(false); + }); + + it("matches pathnames regardless of search params or hashes in href or to", () => { + mockNavigating.to = { url: new URL("https://tangled.org/alice/repo?tab=overview#section") }; + expect(navigatingTo("/alice/repo")).toBe(true); + expect(navigatingTo("/alice/repo?tab=repos")).toBe(true); + }); +}); diff --git a/web/src/lib/navPulse.ts b/web/src/lib/navPulse.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/navPulse.ts @@ -0,0 +1,8 @@ +import { navigating } from "$app/state"; + +// the old page stays mounted while the destination loads, pulse the clicked row +export function navigatingTo(href: string): boolean { + const to = navigating.to?.url; + if (!to) return false; + return to.pathname === new URL(href, to.origin).pathname; +} diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -47,12 +47,16 @@
- +
-

+

{copy.status} — {copy.title}

{copy.message}

diff --git a/web/src/lib/api/blob.ts b/web/src/lib/api/blob.ts --- a/web/src/lib/api/blob.ts +++ b/web/src/lib/api/blob.ts @@ -86,7 +86,8 @@ ref: string, path: string ): Promise => { - const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); + const repo = await parent.repo; + const git = gitTarget(parent.publicConfig, repo, event.fetch); // the knot 404s directories and missing paths alike // knot2's repo_blob answers 413 over its serving limit instead of @@ -136,7 +137,8 @@ kind === "markup" && contents !== null ? await renderReadme( { filename: path, contents }, - parent, + repo, + parent.publicConfig, event, ref, path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined diff --git a/web/src/lib/api/commitStatuses.ts b/web/src/lib/api/commitStatuses.ts --- a/web/src/lib/api/commitStatuses.ts +++ b/web/src/lib/api/commitStatuses.ts @@ -3,7 +3,8 @@ export type CommitStatuses = Record; -const never = new Promise(() => {}); +// the server has no spindle session, resolve empty so the boundary doesn't hang +const none: Promise = Promise.resolve({}); export const commitStatuses = ( spindle: string | undefined, @@ -13,14 +14,12 @@ ): Promise | undefined => { // only repos with a spindle run CI if (!spindle || shas.length === 0) return undefined; - if (!browser) return never; + if (!browser) return none; const params = new URLSearchParams(); for (const sha of new Set(shas)) params.append("sha", sha); const path = `/${encodeURIComponent(ownerHandle)}/${encodeURIComponent(repoName)}/commit-statuses?${params}`; - return ( - fetch(path) - .then((res) => (res.ok ? (res.json() as Promise) : ({} as CommitStatuses))) - .catch(() => ({})) - ); + return fetch(path) + .then((res) => (res.ok ? (res.json() as Promise) : ({} as CommitStatuses))) + .catch(() => ({})); }; diff --git a/web/src/lib/api/load.ts b/web/src/lib/api/load.ts --- a/web/src/lib/api/load.ts +++ b/web/src/lib/api/load.ts @@ -41,6 +41,12 @@ return out; }; +// unhandled rejections kill node if nothing on the page awaits the promise +export const stream = (promise: Promise): Promise => { + promise.catch(() => {}); + return promise; +}; + // per-request promise de-dupe cache. export interface RequestCache { run(key: string, load: () => Promise): Promise; diff --git a/web/src/lib/api/pullCompose.ts b/web/src/lib/api/pullCompose.ts --- a/web/src/lib/api/pullCompose.ts +++ b/web/src/lib/api/pullCompose.ts @@ -1,3 +1,4 @@ +import { error } from "@sveltejs/kit"; import type { Did } from "@atcute/lexicons/syntax"; import type { RepoInfo } from "$lib/components/repo/types"; import { createBobbinClient } from "./client"; @@ -117,15 +118,15 @@ // on the identity export const loadCompose = async (args: { config: GitServiceConfig; - repo: RepoInfo; + repo: RepoInfo | Promise; viewer: { did: string; handle: string } | null; params: URLSearchParams; fetch: typeof globalThis.fetch; }): Promise => { - const { config, repo, viewer, params, fetch: fetchFn } = args; + const { config, repo: repoInput, viewer, params, fetch: fetchFn } = args; + const repo = await repoInput; const repoDid = repo.repoDid; - if (!repoDid) throw new Error("This repository has not been indexed yet"); - + if (!repoDid) throw error(404, "This repository has not been indexed yet"); const ctx = createBobbinClient({ serviceUrl: config.bobbinUrl, fetch: fetchFn }); const source: PullSource = parseSource(params.get("source")) ?? "branch"; diff --git a/web/src/lib/api/repoIndex.ts b/web/src/lib/api/repoIndex.ts --- a/web/src/lib/api/repoIndex.ts +++ b/web/src/lib/api/repoIndex.ts @@ -9,7 +9,7 @@ tags as gitTags, tree as gitTree } from "$lib/api/gitclient"; -import { parallel } from "$lib/api/load"; +import { parallel, stream } from "$lib/api/load"; import { sortBranches, sortTreeEntries, @@ -24,15 +24,15 @@ import type { CommitSummary, LanguageSlice, RepoInfo } from "$lib/components/repo/types"; // `/tree/{ref}` is this same page at another ref, so they share a load -const COMMIT_LIMIT = 10; -const BRANCH_LIMIT = 5; -const TAG_LIMIT = 5; +export const COMMIT_LIMIT = 10; +export const BRANCH_LIMIT = 5; +export const TAG_LIMIT = 5; // a knot only ever lists 100 refs, so any total we get is really a minimum export const REF_LIMIT = 100; export interface RepoParent { publicConfig: { bobbinUrl: string; knotMirrorUrl: string; camoEnabled: boolean }; - repo: RepoInfo; + repo: RepoInfo | Promise; } export interface RepoLoadEvent { @@ -96,18 +96,19 @@ export const renderReadme = ( readme: { filename: string; contents: string } | null, - parent: RepoParent, + repo: RepoInfo, + publicConfig: { camoEnabled: boolean }, event: RepoLoadEvent, ref: string, dir?: string ) => readme ? renderDocument(readme.filename, readme.contents, { - repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, + repo: `${repo.ownerHandle}/${repo.name}`, ref, dir, host: event.url.host, - camo: parent.publicConfig.camoEnabled + camo: publicConfig.camoEnabled }) : Promise.resolve(null); @@ -143,95 +144,119 @@ ); }; -export const loadRepoIndex = async ( +export const loadRepoIndex = ( event: RepoLoadEvent, parent: RepoParent, - ref: string, + ref?: string, // a ref from the url has to resolve or a typo looks like a repo with no // files. the default branch renders whatever the knot managed to answer { requireRef = false }: RepoIndexOptions = {} ) => { - const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); + const repoP = Promise.resolve(parent.repo); // each list falls back on its own, so half a page still renders - const results = await parallel({ - tree: attempt(gitTree(git, { ref })), - log: attempt(gitLog(git, { ref, limit: COMMIT_LIMIT })), - branches: attempt(gitBranches(git, REF_LIMIT)), - tags: attempt(gitTags(git, REF_LIMIT)), - languages: attempt(gitLanguages(git, ref)) + const content = repoP.then(async (repo) => { + const targetRef = ref ?? (await repo.defaultBranch); + const git = gitTarget(parent.publicConfig, repo, event.fetch); + + const results = await parallel({ + tree: attempt(gitTree(git, { ref: targetRef })), + log: attempt(gitLog(git, { ref: targetRef, limit: COMMIT_LIMIT })), + branches: attempt(gitBranches(git, REF_LIMIT)), + tags: attempt(gitTags(git, REF_LIMIT)) + }); + + const branches = sortBranches( + (results.branches.value?.branches ?? []).map(toBranchSummary) + ); + const tags = (results.tags.value?.tags ?? []).map(toTagSummary); + const commits = await withAuthorHandles( + (results.log.value?.commits ?? []).map(toCommitSummary), + parent.publicConfig.bobbinUrl, + event.fetch + ); + const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary)); + + const contentAttempts = [results.tree, results.log, results.branches]; + const knot = classifyRepoAvailability(contentAttempts); + const availability: RepoAvailability = + knot === "ok" && files.length === 0 && branches.length === 0 ? "empty" : knot; + + // there are refs but not this one, so it is not a real ref. an empty repo has + // no refs at all and still gets a page + if (requireRef && results.tree.value === null && branches.length > 0) { + error(404, `${targetRef} does not exist in this repository`); + } + + return { + ref: targetRef, + availability, + files, + readme: readmeOf(results.tree.value), + commits, + tagsByCommit: tagsByCommitHash(commits, tags), + totalCommits: results.log.value?.total ?? commits.length, + branches: branches.slice(0, BRANCH_LIMIT), + totalBranches: branches.length, + tags: tags.slice(0, TAG_LIMIT), + totalTags: tags.length, + // the switcher needs every ref, not just the visible slice + refs: refNames(branches, tags) + }; }); - const branches = sortBranches((results.branches.value?.branches ?? []).map(toBranchSummary)); - const tags = (results.tags.value?.tags ?? []).map(toTagSummary); - const commits = await withAuthorHandles( - (results.log.value?.commits ?? []).map(toCommitSummary), - parent.publicConfig.bobbinUrl, - event.fetch + const languages = repoP.then(async (repo) => { + const targetRef = ref ?? (await repo.defaultBranch); + const git = gitTarget(parent.publicConfig, repo, event.fetch); + const result = await attempt(gitLanguages(git, targetRef)); + return toLanguageSlices(result.value?.languages ?? []); + }); + + const readmeHtml = Promise.all([repoP, content]).then(([repo, resolved]) => + renderReadme(resolved.readme, repo, parent.publicConfig, event, resolved.ref) ); - const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary)); - - const languages = toLanguageSlices(results.languages.value?.languages ?? []); - - const readme = readmeOf(results.tree.value); - const readmeHtml = await renderReadme(readme, parent, event, ref); - - const contentAttempts = [results.tree, results.log, results.branches]; - const knot = classifyRepoAvailability(contentAttempts); - const availability: RepoAvailability = - knot === "ok" && files.length === 0 && branches.length === 0 ? "empty" : knot; - - // there are refs but not this one, so it is not a real ref. an empty repo has - // no refs at all and still gets a page - if (requireRef && results.tree.value === null && branches.length > 0) { - error(404, `${ref} does not exist in this repository`); - } return { - ref, - availability, - files, - readme, - readmeHtml, - commits, - tagsByCommit: tagsByCommitHash(commits, tags), - totalCommits: results.log.value?.total ?? commits.length, - branches: branches.slice(0, BRANCH_LIMIT), - totalBranches: branches.length, - tags: tags.slice(0, TAG_LIMIT), - totalTags: tags.length, - // the switcher needs every ref, not just the visible slice - refs: refNames(branches, tags), - languages + ref: ref ?? "", + content: stream(content), + languages: stream(languages), + readmeHtml: stream(readmeHtml) }; }; -export const loadRepoTree = async ( +export const loadRepoTree = ( event: RepoLoadEvent, parent: RepoParent, ref: string, path: string ) => { - const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); + const repoP = Promise.resolve(parent.repo); - // the tree is the whole page here, so a miss is just a 404 - const tree = await orNull(gitTree(git, { ref, path })); - const files = sortTreeEntries((tree?.files ?? []).map(toTreeEntrySummary)); - // git cannot store an empty directory. so nothing here means the path is a - // file, or was never there - if (tree === null || files.length === 0) { - error(404, `${path} does not exist at ${ref}`); - } + const tree = repoP.then(async (repo) => { + const git = gitTarget(parent.publicConfig, repo, event.fetch); + const result = await orNull(gitTree(git, { ref, path })); + const files = sortTreeEntries((result?.files ?? []).map(toTreeEntrySummary)); + // git cannot store an empty directory. so nothing here means the path is a + // file, or was never there + if (result === null || files.length === 0) { + error(404, `${path} does not exist at ${ref}`); + } - const readme = readmeOf(tree); - const readmeHtml = await renderReadme(readme, parent, event, ref, path); + return { + files, + readme: readmeOf(result), + lastCommit: result.lastCommit ? toTreeCommitSummary(result.lastCommit) : null + }; + }); + + const readmeHtml = Promise.all([repoP, tree]).then(([repo, resolved]) => + renderReadme(resolved.readme, repo, parent.publicConfig, event, ref, path) + ); return { ref, path, - files, - readme, - readmeHtml, - lastCommit: tree.lastCommit ? toTreeCommitSummary(tree.lastCommit) : null + tree: stream(tree), + readmeHtml: stream(readmeHtml) }; }; diff --git a/web/src/routes/[handle]/+layout.svelte b/web/src/routes/[handle]/+layout.svelte --- a/web/src/routes/[handle]/+layout.svelte +++ b/web/src/routes/[handle]/+layout.svelte @@ -1,89 +1,77 @@ - {data.identity.handle} · Tangled + {decodeURIComponent(page.params.handle ?? "")} · Tangled
- {#if data.notJoined} - - {:else} - - - -
- {@render children()} -
-
- {/if} + + {#key page.params.handle} + + {#snippet failed(cause: unknown)} +
+ +
+ {/snippet} + +
+ {/key}
diff --git a/web/src/routes/[handle]/+layout.ts b/web/src/routes/[handle]/+layout.ts --- a/web/src/routes/[handle]/+layout.ts +++ b/web/src/routes/[handle]/+layout.ts @@ -1,3 +1,4 @@ +import { browser } from "$app/environment"; import { error, redirect } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import type { MiniDoc } from "$lib/api/identity"; @@ -12,7 +13,7 @@ STRING_COUNT, VOUCH_COUNT } from "$lib/api/descriptors"; -import { toHttpError, httpStatusFor } from "$lib/api/load"; +import { toHttpError, httpStatusFor, stream } from "$lib/api/load"; import { ClientResponseError } from "$lib/api/client"; import { getViewerVouch } from "$lib/api/graph"; import type { ProfileCounts, DirectVouch } from "$lib/components/profile/types"; @@ -36,60 +37,96 @@ error(404, "Not found"); } - const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); const viewerDid = parent.auth?.did; - const resolved = await enrich(ctx, { - xrpc: "blue.microcosm.identity.resolveMiniDoc", - params: { identifier }, - enrich: targetAll([...PROFILE_COUNTS, FOLLOW_VIEWER], ["did"]), - viewer: viewerDid - }).catch((cause) => toHttpError(cause, "Could not resolve user")); - const doc = resolved.output; - // redirects dids and stale handles to the canonical handle url - const canonical = doc.handle && !doc.handle.endsWith(".invalid") ? doc.handle : null; - if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { - redirect(307, `/${canonical}${event.url.search}`); + // server awaits for 404s and redirects, client navigation streams for instant commit + const resolved = (async () => { + const enriched = await enrich(ctx, { + xrpc: "blue.microcosm.identity.resolveMiniDoc", + params: { identifier }, + enrich: targetAll([...PROFILE_COUNTS, FOLLOW_VIEWER], ["did"]), + viewer: viewerDid + }).catch((cause) => toHttpError(cause, "Could not resolve user")); + const doc = enriched.output; + const did = doc.did; + + const counts: ProfileCounts = { + repos: countOf(enriched.data, did, REPO_COUNT), + strings: countOf(enriched.data, did, STRING_COUNT), + stars: countOf(enriched.data, did, STARRED_COUNT), + followers: countOf(enriched.data, did, FOLLOWER_COUNT), + following: countOf(enriched.data, did, FOLLOWING_COUNT), + vouches: countOf(enriched.data, did, VOUCH_COUNT) + }; + + return { + identity: { did, handle: doc.handle }, + counts, + viewerFollowRkey: + viewerDid && viewerDid !== did + ? viewerRkeyOf(enriched.data, did, FOLLOW_VIEWER) + : null, + canonical: doc.handle && !doc.handle.endsWith(".invalid") ? doc.handle : null + }; + })(); + + // stream at creation, a rejection on the server kills node before the return runs + const identityPromise = stream(resolved.then(({ identity }) => identity)); + const countsPromise = stream(resolved.then(({ counts }) => counts)); + const followPromise = stream(resolved.then(({ viewerFollowRkey }) => viewerFollowRkey)); + + const profilePromise = stream( + resolved.then(({ identity }): Promise => + getProfile(ctx, identity.did) + .then((view) => view.value) + .catch((cause) => { + if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) + return null; + return toHttpError(cause, "Could not load profile"); + }) + ) + ); + + // direct vouch is public so it loads here, network vouches require viewer auth client-side + const vouchPromise = stream( + resolved.then(({ identity }): Promise | null => + viewerDid && identity.did !== viewerDid + ? getViewerVouch(ctx, viewerDid, identity.did).catch(() => null) + : null + ) + ); + + const notJoinedPromise = stream( + Promise.all([profilePromise, countsPromise]).then( + ([profile, counts]) => !profile && Object.values(counts).every((n) => n === 0) + ) + ); + + if (!browser) { + const { identity, canonical } = await resolved; + if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { + redirect(307, `/${canonical}${event.url.search}`); + } + return { + identity, + profile: profilePromise, + counts: countsPromise, + viewerFollowRkey: followPromise, + viewerVouch: vouchPromise, + notJoined: notJoinedPromise + }; } - const did = doc.did; - - const profile: ProfileRecord | null = await getProfile(ctx, did) - .then((view) => view.value) - .catch((cause) => { - if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; - return toHttpError(cause, "Could not load profile"); - }); - - const counts: ProfileCounts = { - repos: countOf(resolved.data, did, REPO_COUNT), - strings: countOf(resolved.data, did, STRING_COUNT), - stars: countOf(resolved.data, did, STARRED_COUNT), - followers: countOf(resolved.data, did, FOLLOWER_COUNT), - following: countOf(resolved.data, did, FOLLOWING_COUNT), - vouches: countOf(resolved.data, did, VOUCH_COUNT) - }; - const viewerFollowRkey = - viewerDid && viewerDid !== did ? viewerRkeyOf(resolved.data, did, FOLLOW_VIEWER) : null; - - // the viewer's own vouch drives the button state. it's readable without auth - // via the viewer's own outgoing vouches, so it stays in the server load. the - // network-vouches list is viewer-scoped and needs a service-auth'd request - // (bobbin derives the network from the authenticated viewer), so it's fetched - // client-side in the layout component where the oauth agent is available. - const viewerVouch: DirectVouch | null = - viewerDid && did !== viewerDid - ? await getViewerVouch(ctx, viewerDid, did).catch(() => null) - : null; - - const notJoined = !profile && Object.values(counts).every((n) => n === 0); - return { - identity: { did, handle: doc.handle }, - profile, - counts, - viewerFollowRkey, - viewerVouch, - notJoined + identity: identityPromise, + profile: profilePromise, + counts: countsPromise, + viewerFollowRkey: followPromise, + viewerVouch: vouchPromise, + notJoined: notJoinedPromise }; }; diff --git a/web/src/routes/[handle]/+page.svelte b/web/src/routes/[handle]/+page.svelte --- a/web/src/routes/[handle]/+page.svelte +++ b/web/src/routes/[handle]/+page.svelte @@ -5,58 +5,73 @@ import StarredTab from "$lib/components/profile/tabs/StarredTab.svelte"; import StringListTab from "$lib/components/profile/tabs/StringListTab.svelte"; import VouchTab from "$lib/components/profile/tabs/VouchTab.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; let { data } = $props(); {#if data.tab === "overview"} - + + {@const identity = await data.identity} + + {:else if data.tab === "repos"} - {#key data.repos} + + {@const identity = await data.identity} + {@const counts = await data.counts} - {/key} + {:else if data.tab === "starred"} - {#key data.stars} - - {/key} + + {@const identity = await data.identity} + {@const counts = await data.counts} + + {:else if data.tab === "strings"} - {#key data.strings} + + {@const identity = await data.identity} + {@const counts = await data.counts} - {/key} + {:else if data.tab === "followers"} - {#key data.people} + + {@const identity = await data.identity} + {@const counts = await data.counts} - {/key} + {:else if data.tab === "following"} - {#key data.people} + + {@const identity = await data.identity} + {@const counts = await data.counts} - {/key} + {:else if data.tab === "vouches"} - {#key data.identity.did} - - {/key} + + {@const identity = await data.identity} + + {/if} diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts --- a/web/src/routes/[handle]/+page.ts +++ b/web/src/routes/[handle]/+page.ts @@ -1,6 +1,6 @@ import type { Did } from "@atcute/lexicons/syntax"; import { createBobbinClient } from "$lib/api/client"; -import { toHttpError } from "$lib/api/load"; +import { stream } from "$lib/api/load"; import { fetchReposPage, fetchStringsPage, @@ -28,55 +28,67 @@ const parent = await event.parent(); const tab = normalizeTab(event.url.searchParams.get("tab")); - if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } }; - - const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - const did = parent.identity.did as Did; - const handle = parent.identity.handle; + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); + // the tab fetches hang off identity without awaiting it, so the navigation commits right away + const identity = Promise.resolve(parent.identity); + const profile = Promise.resolve(parent.profile); const viewerDid = parent.auth?.did; - try { - switch (tab) { - case "repos": { - const q = event.url.searchParams.get("q")?.trim(); - const page = await fetchReposPage(ctx, { did, handle, viewerDid, q: q || undefined }); - return { tab: "repos" as const, repos: page.items }; - } - case "strings": { - const page = await fetchStringsPage(ctx, { did, handle }); - return { tab: "strings" as const, strings: page.items }; - } - case "followers": - case "following": { - const page = await fetchPeoplePage(ctx, { did, viewerDid, direction: tab }); - return { tab, people: page.items } as const; - } - case "vouches": { - // the vouch network is viewer-scoped and needs a service-auth'd request, - // so it's fetched client-side in VouchTab. nothing to prefetch here. - return { - tab: "vouches" as const, - isSelf: viewerDid === did, - profileHandle: handle - }; - } - case "starred": { - const page = await fetchStarredPage(ctx, { did, viewerDid }); - return { tab: "starred" as const, stars: page.items }; - } - case "overview": - default: { - const pinned = await fetchPinned(ctx, { - keys: parent.profile?.pinnedRepositories ?? [], - did, + switch (tab) { + case "repos": { + const q = event.url.searchParams.get("q")?.trim(); + const repos = identity.then(({ did, handle }) => + fetchReposPage(ctx, { + did: did as Did, + handle, + viewerDid, + q: q || undefined + }).then((page) => page.items) + ); + return { tab: "repos" as const, repos: stream(repos) }; + } + case "strings": { + const strings = identity.then(({ did, handle }) => + fetchStringsPage(ctx, { did: did as Did, handle }).then((page) => page.items) + ); + return { tab: "strings" as const, strings: stream(strings) }; + } + case "followers": + case "following": { + const people = identity.then(({ did }) => + fetchPeoplePage(ctx, { did: did as Did, viewerDid, direction: tab }).then( + (page) => page.items + ) + ); + return { tab, people: stream(people) }; + } + case "vouches": { + // network vouches require viewer auth, fetched client-side in VouchTab + return { + tab: "vouches" as const, + isSelf: stream(identity.then(({ did }) => viewerDid === did)) + }; + } + case "starred": { + const stars = identity.then(({ did }) => + fetchStarredPage(ctx, { did: did as Did, viewerDid }).then((page) => page.items) + ); + return { tab: "starred" as const, stars: stream(stars) }; + } + case "overview": + default: { + const pinned = Promise.all([identity, profile]).then(([{ did, handle }, record]) => + fetchPinned(ctx, { + keys: record?.pinnedRepositories ?? [], + did: did as Did, handle, viewerDid - }); - return { tab: "overview" as const, overview: { pinned } }; - } + }) + ); + return { tab: "overview" as const, overview: { pinned: stream(pinned) } }; } - } catch (cause) { - console.error("Page load error:", cause); - toHttpError(cause, "Could not load profile data"); } }; diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -16,7 +16,12 @@ { id: "profile", label: "Profile", href: "/settings", icon: User }, { id: "keys", label: "Keys", href: "/settings/keys", icon: Key }, { id: "emails", label: "Emails", href: "/settings/emails", icon: Mail }, - { id: "notifications", label: "Notifications", href: "/settings/notifications", icon: Bell }, + { + id: "notifications", + label: "Notifications", + href: "/settings/notifications", + icon: Bell + }, { id: "knots", label: "Knots", href: "/settings/knots", icon: Volleyball }, { id: "spindles", label: "Spindles", href: "/settings/spindles", icon: Spool }, { id: "sites", label: "Sites", href: "/settings/sites", icon: Globe } diff --git a/web/src/lib/components/profile/ProfileChrome.svelte b/web/src/lib/components/profile/ProfileChrome.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/profile/ProfileChrome.svelte @@ -0,0 +1,98 @@ + + +{#if notJoined} + +{:else} + + + +
+ {@render content()} +
+
+{/if} diff --git a/web/src/lib/components/profile/ProfileShell.svelte b/web/src/lib/components/profile/ProfileShell.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/profile/ProfileShell.svelte @@ -0,0 +1,55 @@ + + + diff --git a/web/src/lib/components/profile/ProfileTabs.svelte b/web/src/lib/components/profile/ProfileTabs.svelte --- a/web/src/lib/components/profile/ProfileTabs.svelte +++ b/web/src/lib/components/profile/ProfileTabs.svelte @@ -26,7 +26,13 @@ count: counts.repos, href: hrefFor("repos") }, - { id: "starred", label: "Starred", icon: Star, count: counts.stars, href: hrefFor("starred") }, + { + id: "starred", + label: "Starred", + icon: Star, + count: counts.stars, + href: hrefFor("starred") + }, { id: "strings", label: "Strings", diff --git a/web/src/lib/components/profile/StringCardContent.svelte b/web/src/lib/components/profile/StringCardContent.svelte --- a/web/src/lib/components/profile/StringCardContent.svelte +++ b/web/src/lib/components/profile/StringCardContent.svelte @@ -19,11 +19,13 @@
{#if entry.description} -

{entry.description}

+

+ {entry.description} +

{/if}
{entry.lines} line{entry.lines === 1 ? "" : "s"} · {compactRelativeTime( diff --git a/web/src/lib/components/profile/VouchCard.svelte b/web/src/lib/components/profile/VouchCard.svelte --- a/web/src/lib/components/profile/VouchCard.svelte +++ b/web/src/lib/components/profile/VouchCard.svelte @@ -45,7 +45,9 @@ {:else} {profileLabel} {/if} - {relativeTime(vouch.createdAt)} + {relativeTime(vouch.createdAt)}
{#if vouch.reason}

{vouch.reason}

diff --git a/web/src/lib/components/repo/BlobHeader.stories.svelte b/web/src/lib/components/repo/BlobHeader.stories.svelte --- a/web/src/lib/components/repo/BlobHeader.stories.svelte +++ b/web/src/lib/components/repo/BlobHeader.stories.svelte @@ -79,7 +79,13 @@ { await expect(canvas.getByText("spindle")).toBeVisible(); await expect(canvas.queryByRole("link", { name: "View raw" })).toBeNull(); diff --git a/web/src/lib/components/repo/BlobHeader.svelte b/web/src/lib/components/repo/BlobHeader.svelte --- a/web/src/lib/components/repo/BlobHeader.svelte +++ b/web/src/lib/components/repo/BlobHeader.svelte @@ -92,7 +92,10 @@ {#if kind !== "submodule"} {#if hasRenderedView(kind)} - {/if} diff --git a/web/src/lib/components/repo/BlobView.svelte b/web/src/lib/components/repo/BlobView.svelte --- a/web/src/lib/components/repo/BlobView.svelte +++ b/web/src/lib/components/repo/BlobView.svelte @@ -58,7 +58,9 @@ let wrap = $state(false); - const textViewActive = $derived(hasTextView(blob.kind) && view === "code" && !blob.fileTooLarge); + const textViewActive = $derived( + hasTextView(blob.kind) && view === "code" && !blob.fileTooLarge + ); let overflows = $state(false); @@ -126,7 +128,9 @@ {:else if blob.fileTooLarge}

This file is too large to render. - View raw. + View raw.

{:else if blob.kind === "image" || (blob.kind === "svg" && view === "rendered")}
@@ -147,7 +151,13 @@
{@html blob.renderedHtml}
{:else if blob.contents !== null}
- +
{:else}

diff --git a/web/src/lib/components/repo/BranchTable.svelte b/web/src/lib/components/repo/BranchTable.svelte --- a/web/src/lib/components/repo/BranchTable.svelte +++ b/web/src/lib/components/repo/BranchTable.svelte @@ -77,7 +77,11 @@

{#each branches as branch, index (branch.name)} -
+
- + {branch.hash.slice(0, 8)} {#if branch.when} diff --git a/web/src/lib/components/repo/CloneDropdown.stories.svelte b/web/src/lib/components/repo/CloneDropdown.stories.svelte --- a/web/src/lib/components/repo/CloneDropdown.stories.svelte +++ b/web/src/lib/components/repo/CloneDropdown.stories.svelte @@ -12,7 +12,7 @@ ownerHandle: "dawn", repoDid: "did:plc:repo", knot: "knot1.tangled.sh", - defaultBranch: "main" + defaultBranch: Promise.resolve("main") }; const selfHostedRepo = { ...repo, knot: "https://git.example.test:8443" }; diff --git a/web/src/lib/components/repo/CloneDropdown.svelte b/web/src/lib/components/repo/CloneDropdown.svelte --- a/web/src/lib/components/repo/CloneDropdown.svelte +++ b/web/src/lib/components/repo/CloneDropdown.svelte @@ -48,7 +48,6 @@ url.searchParams.set("format", format); return url.toString(); }; - -

Clone this repository

+

+ Clone this repository +

{#if repo.repoDid} - + Use permalink {/if} diff --git a/web/src/lib/components/repo/CommitHeader.svelte b/web/src/lib/components/repo/CommitHeader.svelte --- a/web/src/lib/components/repo/CommitHeader.svelte +++ b/web/src/lib/components/repo/CommitHeader.svelte @@ -2,6 +2,7 @@ import { resolve } from "$app/paths"; import type { CommitDetail } from "$lib/api/repo"; import Avatar from "$lib/components/ui/Avatar.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import { formatDateTime } from "$lib/format"; import type { CommitStatuses } from "$lib/api/commitStatuses"; @@ -125,18 +126,19 @@
{#if pipelineStatuses} - {#await pipelineStatuses then statuses} - {#if statuses[commit.hash]} + + {@const pipeline = (await pipelineStatuses)[commit.hash]} + {#if pipeline}
{/if} - {/await} +
{/if} diff --git a/web/src/lib/components/repo/CommitList.svelte b/web/src/lib/components/repo/CommitList.svelte --- a/web/src/lib/components/repo/CommitList.svelte +++ b/web/src/lib/components/repo/CommitList.svelte @@ -3,6 +3,7 @@ import Ellipsis from "$icon/ellipsis"; import Tag from "$lib/components/ui/Tag.svelte"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import User from "$lib/components/ui/User.svelte"; import Separator from "../ui/Separator.svelte"; import PipelineWorkflows from "./pipelines/PipelineWorkflows.svelte"; @@ -14,7 +15,7 @@ repoName: string; commits: CommitSummary[]; tagsByCommit?: Record; - pipelineStatuses?: Promise; + pipelineStatuses?: Promise; } let { ownerHandle, repoName, commits, tagsByCommit = {}, pipelineStatuses }: Props = $props(); @@ -47,7 +48,9 @@
{#if commit.body && expanded[commit.hash]} -

+

{commit.body}

{/if} @@ -75,7 +78,12 @@ {:else if commit.authorName} - + {/if} {#if commit.when} @@ -88,17 +96,18 @@ {/each} {/if} {#if pipelineStatuses} - {#await pipelineStatuses then statuses} - {#if statuses[commit.hash]} + + {@const pipeline = (await pipelineStatuses)?.[commit.hash]} + {#if pipeline} {/if} - {/await} + {/if}
diff --git a/web/src/lib/components/repo/CommitLogView.svelte b/web/src/lib/components/repo/CommitLogView.svelte --- a/web/src/lib/components/repo/CommitLogView.svelte +++ b/web/src/lib/components/repo/CommitLogView.svelte @@ -6,6 +6,7 @@ import FolderCode from "$icon/folder-code"; import Avatar from "$lib/components/ui/Avatar.svelte"; import Pagination from "$lib/components/ui/Pagination.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import Tag from "$lib/components/ui/Tag.svelte"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import { createCopyFeedback } from "$lib/copy.svelte"; @@ -18,7 +19,7 @@ repoName: string; ref: string; commits: CommitSummary[]; - tagsByCommit?: Record; + tagsByCommit?: Record | Promise>; pipelineStatuses?: Promise; page: number; pageCount: number; @@ -30,7 +31,7 @@ repoName, ref, commits, - tagsByCommit = {}, + tagsByCommit, pipelineStatuses, page, pageCount, @@ -99,11 +100,12 @@ {#snippet pipelineCell(commit: CommitSummary)} {#if pipelineStatuses} - {#await pipelineStatuses then statuses} - {#if statuses[commit.hash]} - + + {@const pipeline = (await pipelineStatuses)[commit.hash]} + {#if pipeline} + {/if} - {/await} + {/if} {/snippet} @@ -127,9 +129,13 @@
{#if commit.body && expanded[commit.hash]}

diff --git a/web/src/lib/components/repo/DiffFileCard.svelte b/web/src/lib/components/repo/DiffFileCard.svelte --- a/web/src/lib/components/repo/DiffFileCard.svelte +++ b/web/src/lib/components/repo/DiffFileCard.svelte @@ -95,6 +95,10 @@ This is a binary file and will not be displayed.

{:else} - + {/if} diff --git a/web/src/lib/components/repo/DiffStatPill.svelte b/web/src/lib/components/repo/DiffStatPill.svelte --- a/web/src/lib/components/repo/DiffStatPill.svelte +++ b/web/src/lib/components/repo/DiffStatPill.svelte @@ -10,20 +10,28 @@ {#if stat.insertions > 0 || stat.deletions > 0} -
+
{#if stat.insertions > 0 && stat.deletions > 0} - + +{stat.insertions} - + -{stat.deletions} {:else if stat.insertions > 0} - + +{stat.insertions} {:else} - + -{stat.deletions} {/if} diff --git a/web/src/lib/components/repo/DiffTopbar.stories.svelte b/web/src/lib/components/repo/DiffTopbar.stories.svelte --- a/web/src/lib/components/repo/DiffTopbar.stories.svelte +++ b/web/src/lib/components/repo/DiffTopbar.stories.svelte @@ -87,7 +87,7 @@ {#snippet center()} - Round #2 + Round #2 {/snippet} {#snippet actions()} review panel diff --git a/web/src/lib/components/repo/DiffTopbar.svelte b/web/src/lib/components/repo/DiffTopbar.svelte --- a/web/src/lib/components/repo/DiffTopbar.svelte +++ b/web/src/lib/components/repo/DiffTopbar.svelte @@ -72,11 +72,17 @@ {#if downloadUrls} {/if} diff --git a/web/src/lib/components/repo/DiffView.svelte b/web/src/lib/components/repo/DiffView.svelte --- a/web/src/lib/components/repo/DiffView.svelte +++ b/web/src/lib/components/repo/DiffView.svelte @@ -51,7 +51,9 @@ fileDiff: file.is_binary ? undefined : toFileDiffMetadata(file), prerenderedHTML: file.is_binary ? undefined : prerendered?.[diffRowKey(file)], blobUrl: - blobBase && file.name.new ? `${blobBase}/${encodePathSegments(file.name.new)}` : undefined + blobBase && file.name.new + ? `${blobBase}/${encodePathSegments(file.name.new)}` + : undefined })) ); diff --git a/web/src/lib/components/repo/EmptyRepo.stories.svelte b/web/src/lib/components/repo/EmptyRepo.stories.svelte --- a/web/src/lib/components/repo/EmptyRepo.stories.svelte +++ b/web/src/lib/components/repo/EmptyRepo.stories.svelte @@ -12,7 +12,7 @@ ownerHandle: "dawn", repoDid: "did:plc:repo", knot: "knot1.tangled.sh", - defaultBranch: "main" + defaultBranch: Promise.resolve("main") }; const otherRepo = { ...ownerRepo, ownerDid: "did:plc:other", ownerHandle: "other" }; diff --git a/web/src/lib/components/repo/EmptyRepo.svelte b/web/src/lib/components/repo/EmptyRepo.svelte --- a/web/src/lib/components/repo/EmptyRepo.svelte +++ b/web/src/lib/components/repo/EmptyRepo.svelte @@ -27,6 +27,12 @@ ); const remote = $derived(`git@${sshHost}:${repo.repoDid ?? `${repo.ownerHandle}/${repo.name}`}`); + let defaultBranch = $state("main"); + $effect(() => { + void repo.defaultBranch.then((b) => { + defaultBranch = b; + }); + }); const keyStatus = createLoad(async (): Promise => { if (hasSshKey !== undefined) { return hasSshKey; @@ -56,7 +62,8 @@ {/snippet} {#snippet codeBlock(lines: string[])} -
{lines.join(
+	
{lines.join(
 			"\n"
 		)}
{/snippet} @@ -69,15 +76,17 @@

This is an empty repository.

-

…create a new repository on the command line

+

+ …create a new repository on the command line +

{@render codeBlock([ `echo "# ${repo.name}" >> README.md`, "git init", "git add README.md", 'git commit -m "initial commit"', - `git branch -M ${repo.defaultBranch}`, + `git branch -M ${defaultBranch}`, `git remote add origin ${remote}`, - `git push -u origin ${repo.defaultBranch}` + `git push -u origin ${defaultBranch}` ])}
@@ -86,8 +95,8 @@

{@render codeBlock([ `git remote add origin ${remote}`, - `git branch -M ${repo.defaultBranch}`, - `git push -u origin ${repo.defaultBranch}` + `git branch -M ${defaultBranch}`, + `git push -u origin ${defaultBranch}` ])}
diff --git a/web/src/lib/components/repo/FileTree.svelte b/web/src/lib/components/repo/FileTree.svelte --- a/web/src/lib/components/repo/FileTree.svelte +++ b/web/src/lib/components/repo/FileTree.svelte @@ -5,6 +5,7 @@ import Folder from "$icon/folder"; import FolderInput from "$icon/folder-input"; import { splitMessage } from "$lib/api/repo"; + import { navigatingTo } from "$lib/navPulse"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import type { TreeEntrySummary } from "./types"; import { encodePathSegments } from "./urls"; @@ -52,9 +53,13 @@
{#each entries as entry (entry.name)} {@const Glyph = iconFor(entry)} -
+ {@const href = resolve(linkFor(entry) as "/")} +
{#if commit.authorName} - + {commit.authorName} @@ -35,7 +35,7 @@
{commit.shortHash} diff --git a/web/src/lib/components/repo/PanelHeader.svelte b/web/src/lib/components/repo/PanelHeader.svelte --- a/web/src/lib/components/repo/PanelHeader.svelte +++ b/web/src/lib/components/repo/PanelHeader.svelte @@ -23,7 +23,7 @@
diff --git a/web/src/lib/components/repo/RefSelector.svelte b/web/src/lib/components/repo/RefSelector.svelte --- a/web/src/lib/components/repo/RefSelector.svelte +++ b/web/src/lib/components/repo/RefSelector.svelte @@ -11,19 +11,32 @@ current: string; branches: string[]; tags: string[]; - defaultBranch?: string; + defaultBranch?: string | Promise; } let { ownerHandle, repoName, current, branches, tags, defaultBranch }: Props = $props(); const base = $derived(`/${ownerHandle}/${repoName}`); + let resolvedDefaultBranch = $state(); + $effect(() => { + if (typeof defaultBranch === "string") { + resolvedDefaultBranch = defaultBranch; + } else if (defaultBranch) { + void defaultBranch.then((b) => { + resolvedDefaultBranch = b; + }); + } else { + resolvedDefaultBranch = undefined; + } + }); + // no counts in the headings: the knot caps how many refs it hands back, so the length here // is the loaded count rather than the repo's total, and it would not track the filter either const options = $derived([ ...branches.map((branch) => ({ value: branch, - hint: branch === defaultBranch ? "default" : undefined, + hint: branch === resolvedDefaultBranch ? "default" : undefined, group: "Branches" })), ...(tags.length > 0 diff --git a/web/src/lib/components/repo/RepoCard.svelte b/web/src/lib/components/repo/RepoCard.svelte --- a/web/src/lib/components/repo/RepoCard.svelte +++ b/web/src/lib/components/repo/RepoCard.svelte @@ -1,8 +1,9 @@ - + diff --git a/web/src/lib/components/repo/RepoChrome.svelte b/web/src/lib/components/repo/RepoChrome.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/repo/RepoChrome.svelte @@ -0,0 +1,31 @@ + + + +{#if showTabs} + +{/if} diff --git a/web/src/lib/components/repo/RepoHeader.stories.svelte b/web/src/lib/components/repo/RepoHeader.stories.svelte --- a/web/src/lib/components/repo/RepoHeader.stories.svelte +++ b/web/src/lib/components/repo/RepoHeader.stories.svelte @@ -25,7 +25,7 @@ description: "social code collaboration for the at protocol", website: "https://tangled.org", topics: ["atproto", "git", "svelte"], - defaultBranch: "main", + defaultBranch: Promise.resolve("main"), source: { ownerHandle: "upstream", name: "tangled" } }; const counts = { stars: 128, issues: 7, pulls: 3, forks: 12 }; diff --git a/web/src/lib/components/repo/RepoHeader.svelte b/web/src/lib/components/repo/RepoHeader.svelte --- a/web/src/lib/components/repo/RepoHeader.svelte +++ b/web/src/lib/components/repo/RepoHeader.svelte @@ -6,17 +6,18 @@ import Button from "$lib/components/ui/Button.svelte"; import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; import Tag from "$lib/components/ui/Tag.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import StarButton from "./StarButton.svelte"; - import type { RepoCounts, RepoInfo } from "./types"; + import type { RepoCounts, RepoInfo, RepoSource } from "./types"; import { formatCount } from "$lib/format"; - interface Props { repo: RepoInfo; - counts: RepoCounts; - viewerStarRkey?: string | null; + counts: RepoCounts | Promise; + viewerStarRkey?: string | null | Promise; + source?: RepoSource | null | Promise; } - let { repo, counts, viewerStarRkey }: Props = $props(); + let { repo, counts, viewerStarRkey, source }: Props = $props(); const base = $derived(`/${repo.ownerHandle}/${repo.name}`); const trimScheme = (url: string) => url.replace(/^https?:\/\//, "").replace(/\/$/, ""); @@ -29,7 +30,7 @@ repoDid={repo.repoDid ?? ""} repoOwnerHandle={repo.ownerHandle} repoName={repo.name} - initialCount={counts.stars} + initialCount={counts} initialRkey={viewerStarRkey} /> @@ -50,7 +51,13 @@ size="small" title="Forked by" > - {formatCount(counts.forks)} + {#if typeof counts === "object" && counts !== null && "then" in counts} + + {formatCount((await counts).forks)} + + {:else} + {formatCount(counts.forks)} + {/if}
@@ -76,20 +83,25 @@
- {#if repo.source} - + {/if} +
{@render repoActions("hidden shrink-0 items-start gap-2 sm:flex")} diff --git a/web/src/lib/components/repo/RepoIndexView.stories.svelte b/web/src/lib/components/repo/RepoIndexView.stories.svelte --- a/web/src/lib/components/repo/RepoIndexView.stories.svelte +++ b/web/src/lib/components/repo/RepoIndexView.stories.svelte @@ -13,7 +13,7 @@ ownerHandle: "dawn", repoDid: "did:plc:repo", knot: "knot1.tangled.sh", - defaultBranch: "main" + defaultBranch: Promise.resolve("main") }; const commits = [ @@ -46,7 +46,10 @@ } ]; - const data = { + type IndexData = Awaited>; + type IndexContent = Awaited; + + const content: IndexContent = { ref: "main", availability: "ok", files: [ @@ -54,7 +57,6 @@ { name: "README.md", kind: "file" as const, size: 512 } ], readme: { filename: "README.md", contents: "# tangled" }, - readmeHtml: "

tangled

", commits, tagsByCommit: {}, totalCommits: 42, @@ -69,34 +71,46 @@ totalBranches: 1, tags: [{ name: "v1.0.0", hash: "abcdef0123456789", commitHash: "0123456789abcdef" }], totalTags: 1, - refs: { branches: ["main"], tags: ["v1.0.0"], capped: false }, - languages: [ - { name: "TypeScript", percentage: 70, share: 70 }, - { name: "Svelte", percentage: 30, share: 30 } - ] - } satisfies Awaited>; - const cappedData = { - ...data, + refs: { branches: ["main"], tags: ["v1.0.0"], capped: false } + }; + + const indexData = ( + overrides: Partial = {}, + streamed: Partial> = {} + ): IndexData => ({ + ref: "main", + content: Promise.resolve({ ...content, ...overrides }), + languages: + streamed.languages ?? + Promise.resolve([ + { name: "TypeScript", percentage: 70, share: 70 }, + { name: "Svelte", percentage: 30, share: 30 } + ]), + readmeHtml: streamed.readmeHtml ?? Promise.resolve("

tangled

") + }); + + const data = indexData(); + const cappedData = indexData({ totalBranches: 100, totalTags: 100, - refs: { ...data.refs, capped: true } - } satisfies Awaited>; - const emptyData = { - ...data, - availability: "empty", - files: [], - readme: null, - readmeHtml: null, - commits: [], - tagsByCommit: {}, - totalCommits: 0, - branches: [], - totalBranches: 0, - tags: [], - totalTags: 0, - refs: { branches: [], tags: [], capped: false }, - languages: [] - } satisfies Awaited>; + refs: { ...content.refs, capped: true } + }); + const emptyData = indexData( + { + availability: "empty", + files: [], + readme: null, + commits: [], + tagsByCommit: {}, + totalCommits: 0, + branches: [], + totalBranches: 0, + tags: [], + totalTags: 0, + refs: { branches: [], tags: [], capped: false } + }, + { languages: Promise.resolve([]), readmeHtml: Promise.resolve(null) } + ); const { Story } = defineMeta({ title: "Repo/RepoIndexView", @@ -116,8 +130,8 @@ - - + + diff --git a/web/src/lib/components/repo/RepoIndexView.svelte b/web/src/lib/components/repo/RepoIndexView.svelte --- a/web/src/lib/components/repo/RepoIndexView.svelte +++ b/web/src/lib/components/repo/RepoIndexView.svelte @@ -12,6 +12,7 @@ import Readme from "./Readme.svelte"; import RepoToolbar from "./RepoToolbar.svelte"; import TagList from "./TagList.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; import type { loadRepoIndex } from "$lib/api/repoIndex"; import type { CommitStatuses } from "$lib/api/commitStatuses"; @@ -21,122 +22,137 @@ repo: RepoInfo; data: Awaited>; bobbinUrl: string; - pipelineStatuses?: Promise; + pipelineStatuses?: Promise; } let { repo, data, bobbinUrl, pipelineStatuses }: Props = $props(); const base = $derived(`/${repo.ownerHandle}/${repo.name}`); - const encodedRef = $derived(encodeURIComponent(data.ref)); - const refsCapped = $derived(data.refs.capped); - - {#if data.needsUpgrade} -
-
- + + {@const content = await data.content} + + {#if content.availability === "needs-upgrade"} +
+
+ + +

+ This repository is currently unavailable. + Read the upgrade guide +

+
+
+ {:else if content.availability === "unreachable"} +
+ -

- This repository is currently unavailable. - Read the upgrade guide -

-
- {:else if data.availability === "unreachable"} -
- - -
- {:else if data.availability === "empty"} - - {:else} - {#if data.languages.length > 0} - + {:else if content.availability === "empty"} + + {:else} + + + {@const languages = await data.languages} + {#if languages.length > 0} + + {/if} + + + +
+
+ +
+ + +
{/if} + + - + {@const readme = (await data.content).readme} + {#if readme} + - -
-
- -
- - -
{/if} - - -{#if data.readme} - -{/if} + diff --git a/web/src/lib/components/repo/RepoTabs.stories.svelte b/web/src/lib/components/repo/RepoTabs.stories.svelte --- a/web/src/lib/components/repo/RepoTabs.stories.svelte +++ b/web/src/lib/components/repo/RepoTabs.stories.svelte @@ -10,7 +10,7 @@ ownerDid: "did:plc:owner", ownerHandle: "dawn", knot: "knot1.tangled.sh", - defaultBranch: "main" + defaultBranch: Promise.resolve("main") }; const { Story } = defineMeta({ diff --git a/web/src/lib/components/repo/RepoTabs.svelte b/web/src/lib/components/repo/RepoTabs.svelte --- a/web/src/lib/components/repo/RepoTabs.svelte +++ b/web/src/lib/components/repo/RepoTabs.svelte @@ -9,7 +9,7 @@ interface Props { repo: RepoInfo; - counts: RepoCounts; + counts: RepoCounts | Promise; active: string; } @@ -17,20 +17,27 @@ const base = $derived(`/${repo.ownerHandle}/${repo.name}`); + const countFor = (key: keyof RepoCounts) => { + if (typeof counts === "object" && counts !== null && "then" in counts) { + return (counts as Promise).then((c) => c[key]); + } + return (counts as RepoCounts)[key]; + }; + const tabs = $derived([ { id: "overview", label: "Overview", icon: SquareChartGantt, href: base }, { id: "issues", label: "Issues", icon: CircleDot, - count: counts.issues, + count: countFor("issues"), href: `${base}/issues` }, { id: "pulls", label: "Pulls", icon: GitPullRequest, - count: counts.pulls, + count: countFor("pulls"), href: `${base}/pulls` }, { id: "pipelines", label: "Pipelines", icon: Layers2, href: `${base}/pipelines` }, diff --git a/web/src/lib/components/repo/RepoToolbar.stories.svelte b/web/src/lib/components/repo/RepoToolbar.stories.svelte --- a/web/src/lib/components/repo/RepoToolbar.stories.svelte +++ b/web/src/lib/components/repo/RepoToolbar.stories.svelte @@ -11,7 +11,7 @@ ownerHandle: "dawn", repoDid: "did:plc:repo", knot: "knot1.tangled.sh", - defaultBranch: "main" + defaultBranch: Promise.resolve("main") }; const { Story } = defineMeta({ diff --git a/web/src/lib/components/repo/StarButton.svelte b/web/src/lib/components/repo/StarButton.svelte --- a/web/src/lib/components/repo/StarButton.svelte +++ b/web/src/lib/components/repo/StarButton.svelte @@ -10,16 +10,16 @@ import { getProfileCounts } from "$lib/components/profile/counts.svelte"; import { createOptimisticRelation, createOptimisticCount } from "$lib/optimistic.svelte"; import { formatCount } from "$lib/format"; + import type { RepoCounts } from "./types"; interface Props { repoDid: string; repoOwnerHandle: string; repoName: string; - initialCount?: number; - initialRkey?: string | null; + initialCount?: number | RepoCounts | Promise | Promise; + initialRkey?: string | null | Promise; insetShadow?: boolean; } - let { repoDid, repoOwnerHandle, @@ -32,15 +32,50 @@ const auth = getAuth(); const profileCounts = getProfileCounts(); const signedIn = $derived(Boolean(auth.currentDid)); + let loadedRkey = $state(); + $effect(() => { + if (initialRkey instanceof Promise) { + loadedRkey = undefined; + void initialRkey + .then((rkey) => { + loadedRkey = rkey; + }) + .catch(() => { + loadedRkey = null; + }); + } else { + loadedRkey = initialRkey; + } + }); + + let loadedCount = $state(); + $effect(() => { + if (initialCount instanceof Promise) { + loadedCount = undefined; + void (initialCount as Promise) + .then((val: number | RepoCounts) => { + loadedCount = typeof val === "number" ? val : (val?.stars ?? 0); + }) + .catch(() => { + loadedCount = 0; + }); + } else if (typeof initialCount === "number") { + loadedCount = initialCount; + } else if (typeof initialCount === "object" && initialCount !== null) { + loadedCount = (initialCount as RepoCounts).stars; + } else { + loadedCount = undefined; + } + }); + const relation = createOptimisticRelation({ key: () => `${auth.currentDid ?? ""}:${repoDid}`, - loadedRkey: () => initialRkey + loadedRkey: () => loadedRkey }); const starCount = createOptimisticCount({ key: () => repoDid, - loaded: () => initialCount + loaded: () => loadedCount }); - const starred = $derived(relation.active); const failed = $derived(relation.failed || starCount.failed); // if signed out, sends you to log in first, then back here @@ -92,7 +127,9 @@ {starred ? "Unstar" : "Star"} {#if failed} diff --git a/web/src/lib/components/repo/TagCard.svelte b/web/src/lib/components/repo/TagCard.svelte --- a/web/src/lib/components/repo/TagCard.svelte +++ b/web/src/lib/components/repo/TagCard.svelte @@ -54,7 +54,10 @@ {tag.name}
- + diff --git a/web/src/lib/components/repo/TagList.svelte b/web/src/lib/components/repo/TagList.svelte --- a/web/src/lib/components/repo/TagList.svelte +++ b/web/src/lib/components/repo/TagList.svelte @@ -29,7 +29,10 @@ {#if tag.when || index === 0}
{#if tag.when} - + {/if} {#if index === 0} Latest diff --git a/web/src/lib/components/repo/TreeHeader.svelte b/web/src/lib/components/repo/TreeHeader.svelte --- a/web/src/lib/components/repo/TreeHeader.svelte +++ b/web/src/lib/components/repo/TreeHeader.svelte @@ -20,7 +20,7 @@ const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; -
+
diff --git a/web/src/lib/components/repo/fileDiff.test.ts b/web/src/lib/components/repo/fileDiff.test.ts --- a/web/src/lib/components/repo/fileDiff.test.ts +++ b/web/src/lib/components/repo/fileDiff.test.ts @@ -130,7 +130,9 @@ }); it("marks the file type from the wire flags", () => { - expect(toFileDiffMetadata({ name: { old: "", new: "n.go" }, is_new: true }).type).toBe("new"); + expect(toFileDiffMetadata({ name: { old: "", new: "n.go" }, is_new: true }).type).toBe( + "new" + ); expect(toFileDiffMetadata({ name: { old: "o.go", new: "" }, is_delete: true }).type).toBe( "deleted" ); diff --git a/web/src/lib/components/repo/types.ts b/web/src/lib/components/repo/types.ts --- a/web/src/lib/components/repo/types.ts +++ b/web/src/lib/components/repo/types.ts @@ -15,7 +15,7 @@ website?: string; topics?: string[]; source?: RepoSource; - defaultBranch: string; + defaultBranch: Promise; } /** the repo this one was forked from */ diff --git a/web/src/lib/components/settings/CodeChip.svelte b/web/src/lib/components/settings/CodeChip.svelte --- a/web/src/lib/components/settings/CodeChip.svelte +++ b/web/src/lib/components/settings/CodeChip.svelte @@ -10,7 +10,7 @@ {@render children()} diff --git a/web/src/lib/components/settings/KeyCardContent.svelte b/web/src/lib/components/settings/KeyCardContent.svelte --- a/web/src/lib/components/settings/KeyCardContent.svelte +++ b/web/src/lib/components/settings/KeyCardContent.svelte @@ -23,9 +23,13 @@ {name}
{#if fingerprint} - {fingerprint} + {fingerprint} {/if} - added {relativeTime(createdAt)} + added {relativeTime(createdAt)}
diff --git a/web/src/lib/components/ui/Avatar.svelte b/web/src/lib/components/ui/Avatar.svelte --- a/web/src/lib/components/ui/Avatar.svelte +++ b/web/src/lib/components/ui/Avatar.svelte @@ -58,31 +58,34 @@ src ?? (did ? avatarUrl(did, px === undefined ? undefined : px * 2) : undefined) ); - // an unconfigured avatar service 404s, same as a broken image - let failed = $state(false); + // hidden with a style, not state, a page full of 404s would write state mid-render + const hide = (event: Event) => { + (event.currentTarget as HTMLImageElement).style.display = "none"; + }; - $effect(() => { - if (source) failed = false; - }); + // a reused element has to recover from a previous failure + const show = (event: Event) => { + (event.currentTarget as HTMLImageElement).style.display = ""; + }; -{#if source && !failed} - {handle (failed = true)} - /> -{:else} - - - -{/if} + + + {#if source} + + {/if} + diff --git a/web/src/lib/components/ui/MarkdownEditor.svelte b/web/src/lib/components/ui/MarkdownEditor.svelte --- a/web/src/lib/components/ui/MarkdownEditor.svelte +++ b/web/src/lib/components/ui/MarkdownEditor.svelte @@ -4,6 +4,7 @@ import Pencil from "$icon/pencil"; import Button from "./Button.svelte"; import ButtonGroup, { segmentProps } from "./ButtonGroup.svelte"; + import Pending from "./Pending.svelte"; import Textarea, { textareaMinHeight } from "./Textarea.svelte"; import { renderMarkup, type MarkupContext } from "$lib/markup"; @@ -104,23 +105,34 @@ class={transparent ? "bg-transparent" : undefined} /> {:else} - {#await preview} -
Rendering…
- {:then html} + + {#snippet skeleton()} +
+ Rendering… +
+ {/snippet} + {#snippet failed()} +
+ Could not render preview. +
+ {/snippet} + {@const html = await preview} {#if html}
{@html html}
{:else} -
+
Nothing to preview.
{/if} - {:catch} -
- Could not render preview. -
- {/await} + {/if}
diff --git a/web/src/lib/components/ui/Pending.svelte b/web/src/lib/components/ui/Pending.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/ui/Pending.svelte @@ -0,0 +1,26 @@ + + + +{#if failedProp} + + {@render children()} + {#snippet failed(error: unknown)} + {@render failedProp(error)} + {/snippet} + +{:else} + + {@render children()} + +{/if} diff --git a/web/src/lib/components/ui/Tabs.stories.svelte b/web/src/lib/components/ui/Tabs.stories.svelte --- a/web/src/lib/components/ui/Tabs.stories.svelte +++ b/web/src/lib/components/ui/Tabs.stories.svelte @@ -1,5 +1,6 @@ - {fullName} · Tangled - - - - - - - - + {paramFullName} · Tangled + {#if repoMeta} + + + + + + + + + {/if}
- - {#if !bare} - - {/if} - {@render children()} + + {#key `${page.params.handle}/${page.params.repo}`} + + + {#snippet failed(_error: unknown)} + + {/snippet} + + + + + {@render children()} + {#snippet failed(cause: unknown, reset: () => void)} + {noteFailure(reset)} +
+ +
+ {/snippet} +
+ {/key}
diff --git a/web/src/routes/[handle]/[repo]/+page.svelte b/web/src/routes/[handle]/[repo]/+page.svelte --- a/web/src/routes/[handle]/[repo]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/+page.svelte @@ -1,17 +1,23 @@ - + + {@const repo = await data.repo} + + diff --git a/web/src/routes/[handle]/[repo]/+page.ts b/web/src/routes/[handle]/[repo]/+page.ts --- a/web/src/routes/[handle]/[repo]/+page.ts +++ b/web/src/routes/[handle]/[repo]/+page.ts @@ -3,5 +3,5 @@ export const load: PageLoad = async (event) => { const parent = await event.parent(); - return loadRepoIndex(event, parent, parent.repo.defaultBranch); + return loadRepoIndex(event, parent); }; diff --git a/web/src/routes/repo/new/+page.svelte b/web/src/routes/repo/new/+page.svelte --- a/web/src/routes/repo/new/+page.svelte +++ b/web/src/routes/repo/new/+page.svelte @@ -23,10 +23,12 @@
-

Create a new repository

+

+ Create a new repository +

- Repositories contain a project's files and version history. All repositories are publicly - accessible. + Repositories contain a project's files and version history. All repositories are + publicly accessible.

@@ -42,15 +44,23 @@
-

General

-
Basic repository information.
+

+ General +

+
+ Basic repository information. +
-
+ +{#snippet pendingCount(value: number | Promise)} + + {@const count = await value} + {#if count > 0} + + + {formatCount(count)} pipeline {count === 1 ? "run" : "runs"} + + {/if} + +{/snippet} diff --git a/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte b/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte @@ -0,0 +1,50 @@ + + + + + + +
+ +
diff --git a/web/src/lib/components/repo/pipelines/logStream.svelte.ts b/web/src/lib/components/repo/pipelines/logStream.svelte.ts --- a/web/src/lib/components/repo/pipelines/logStream.svelte.ts +++ b/web/src/lib/components/repo/pipelines/logStream.svelte.ts @@ -1,11 +1,7 @@ import { subscribePipelineLogs } from "$lib/api/spindleLogs"; import { createLogAccumulator, type LogStep } from "./logs"; -export type LogStreamStatus = - | "connecting" - | "streaming" - | "done" - | "dropped"; +export type LogStreamStatus = "connecting" | "streaming" | "done" | "dropped"; export interface LogStreamTarget { host: string; @@ -45,7 +41,8 @@ return; } // ignore frames for other workflows - const name = frame.kind === "control" ? frame.control.workflow : frame.data.workflow; + const name = + frame.kind === "control" ? frame.control.workflow : frame.data.workflow; if (name !== workflow) return; this.#dirty = this.#accumulator.push(frame) || this.#dirty; this.#schedule(); diff --git a/web/src/lib/components/repo/pipelines/logs.test.ts b/web/src/lib/components/repo/pipelines/logs.test.ts --- a/web/src/lib/components/repo/pipelines/logs.test.ts +++ b/web/src/lib/components/repo/pipelines/logs.test.ts @@ -72,7 +72,11 @@ }); it("carries an unterminated colour across lines, like the terminal would", () => { - const steps = drain([start(1, "Run"), line(1, "\u001b[32mstart\n"), line(1, "still green\n")]); + const steps = drain([ + start(1, "Run"), + line(1, "\u001b[32mstart\n"), + line(1, "still green\n") + ]); expect(steps[0].lines[1]).toBe('still green'); }); @@ -120,7 +124,9 @@ "step", "line" ]); - expect(rows.filter((row) => row.kind === "line").map((row) => row.number)).toEqual([1, 2, 1]); + expect(rows.filter((row) => row.kind === "line").map((row) => row.number)).toEqual([ + 1, 2, 1 + ]); }); it("drops the body of a collapsed step but keeps its header", () => { diff --git a/web/src/lib/components/repo/pulls/PullCompose.stories.svelte b/web/src/lib/components/repo/pulls/PullCompose.stories.svelte --- a/web/src/lib/components/repo/pulls/PullCompose.stories.svelte +++ b/web/src/lib/components/repo/pulls/PullCompose.stories.svelte @@ -64,7 +64,7 @@ ownerHandle: "tangled.org", repoDid: "did:plc:wshs7t2adsemcrrd4snkeqli", knot: "knot.example.test", - defaultBranch: "main" + defaultBranch: Promise.resolve("main") }, bobbinUrl: "https://bobbin.example.test", knotMirrorUrl: "https://mirror.example.test", diff --git a/web/src/lib/components/repo/pulls/PullToolbar.svelte b/web/src/lib/components/repo/pulls/PullToolbar.svelte --- a/web/src/lib/components/repo/pulls/PullToolbar.svelte +++ b/web/src/lib/components/repo/pulls/PullToolbar.svelte @@ -7,15 +7,16 @@ import Button from "$lib/components/ui/Button.svelte"; import ButtonGroup, { segmentProps } from "$lib/components/ui/ButtonGroup.svelte"; import PullSearch from "./PullSearch.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import { formatCount } from "$lib/format"; interface Props { ownerHandle: string; repoName: string; state: "open" | "closed" | "merged"; - openCount: number; - closedCount: number; - mergedCount: number; + openCount: number | Promise; + closedCount: number | Promise; + mergedCount: number | Promise; } let { ownerHandle, repoName, state, openCount, closedCount, mergedCount }: Props = $props(); @@ -40,7 +41,7 @@ icon={GitPullRequest} > - + Open @@ -51,7 +52,7 @@ icon={GitMerge} > - + Merged @@ -62,7 +63,7 @@ icon={GitPullRequestClosed} > - + Closed @@ -78,3 +79,10 @@ New
+ + +{#snippet pendingCount(value: number | Promise)} + + {formatCount(await value)} + +{/snippet} diff --git a/web/src/lib/components/settings/tabs/EmailsTab.svelte b/web/src/lib/components/settings/tabs/EmailsTab.svelte --- a/web/src/lib/components/settings/tabs/EmailsTab.svelte +++ b/web/src/lib/components/settings/tabs/EmailsTab.svelte @@ -74,13 +74,19 @@ {/if} {/snippet} {#snippet meta()} - {email.added} + {email.added} {/snippet} {#snippet actions()} {#if !email.primary} - + {/if} - + {/snippet} {/each} diff --git a/web/src/lib/components/settings/tabs/KnotsTab.svelte b/web/src/lib/components/settings/tabs/KnotsTab.svelte --- a/web/src/lib/components/settings/tabs/KnotsTab.svelte +++ b/web/src/lib/components/settings/tabs/KnotsTab.svelte @@ -65,13 +65,17 @@ {/if} {/snippet} {#snippet meta()} - {knot.added} + {knot.added} {/snippet} {#snippet actions()} {#if !knot.verified} {/if} - + {/snippet} {/each} diff --git a/web/src/lib/components/settings/tabs/ProfileTab.svelte b/web/src/lib/components/settings/tabs/ProfileTab.svelte --- a/web/src/lib/components/settings/tabs/ProfileTab.svelte +++ b/web/src/lib/components/settings/tabs/ProfileTab.svelte @@ -137,7 +137,9 @@ - +
diff --git a/web/src/lib/components/settings/tabs/SpindlesTab.svelte b/web/src/lib/components/settings/tabs/SpindlesTab.svelte --- a/web/src/lib/components/settings/tabs/SpindlesTab.svelte +++ b/web/src/lib/components/settings/tabs/SpindlesTab.svelte @@ -62,13 +62,18 @@ {/if} {/snippet} {#snippet meta()} - {spindle.added} + {spindle.added} {/snippet} {#snippet actions()} {#if !spindle.verified} - + {/if} - + {/snippet} {/each} diff --git a/web/src/routes/[handle]/[repo]/branches/+page.svelte b/web/src/routes/[handle]/[repo]/branches/+page.svelte --- a/web/src/routes/[handle]/[repo]/branches/+page.svelte +++ b/web/src/routes/[handle]/[repo]/branches/+page.svelte @@ -3,30 +3,41 @@ import { resolve } from "$app/paths"; import BranchTable from "$lib/components/repo/BranchTable.svelte"; import Pagination from "$lib/components/ui/Pagination.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; let { data } = $props(); - const pageHref = (page: number) => - resolve( - `/${data.repo.ownerHandle}/${data.repo.name}/branches${page > 1 ? `?page=${page}` : ""}` as "/" - ); - const changePage = (page: number) => void goto(pageHref(page)); + const changePage = (page: number) => { + void Promise.resolve(data.repo).then((repo) => { + void goto( + resolve( + `/${repo.ownerHandle}/${repo.name}/branches${page > 1 ? `?page=${page}` : ""}` as "/" + ) + ); + }); + }; - + + {@const repo = await data.repo} + + -{#if data.pageCount > 1} -
- -
-{/if} + + {@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 --- a/web/src/routes/[handle]/[repo]/branches/+page.ts +++ b/web/src/routes/[handle]/[repo]/branches/+page.ts @@ -1,23 +1,29 @@ 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 type { PageLoad } from "./$types"; export const load: PageLoad = async (event) => { const parent = await event.parent(); - const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); + 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 results = await branches(git, REF_LIMIT, cursor); - const list = sortBranches((results.branches ?? []).map(toBranchSummary)); - const total = results.total ?? 0; - const pageCount = - total > 0 ? Math.ceil(total / REF_LIMIT) : page + (list.length === REF_LIMIT ? 1 : 0); + 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 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 { - branches: list, + branches: stream(list), page, - pageCount + pageCount: stream(pageCount) }; }; diff --git a/web/src/routes/[handle]/[repo]/issues/+page.svelte b/web/src/routes/[handle]/[repo]/issues/+page.svelte --- a/web/src/routes/[handle]/[repo]/issues/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/+page.svelte @@ -1,21 +1,32 @@ - - - + + + {@const repo = await data.repo} + + + -
- -
+
+ + + +
+
diff --git a/web/src/routes/[handle]/[repo]/issues/+page.ts b/web/src/routes/[handle]/[repo]/issues/+page.ts --- a/web/src/routes/[handle]/[repo]/issues/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/+page.ts @@ -3,55 +3,58 @@ import { ISSUE_AUTHOR_DOCS } from "$lib/api/descriptors"; import { authorOf, enrich, target } from "$lib/api/enrich"; 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 type { PageLoad } from "./$types"; 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 repoDid = parent.repo.repoDid; + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); - // no repoDid means bobbin never indexed this repo, so there is nothing to list - if (!repoDid) { - return { - state: state as "open" | "closed", - issues: [] as IssueSummary[], - openCount: parent.counts.issues, - closedCount: 0 - }; - } - - const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - - const [page, closed] = await Promise.all([ - enrich(ctx, { + // returned unawaited so the page can paint its skeleton, ssr still waits for them + const issues = repo.then(async (r) => { + const repoDid = r.repoDid; + if (!repoDid) return [] as IssueSummary[]; + return enrich(ctx, { xrpc: "sh.tangled.repo.listIssues", params: { subject: repoDid, state }, enrich: [target(ISSUE_AUTHOR_DOCS, ["items[].uri"])] - }), - // the layout only knows the open count, the closed tab needs its own - count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }).catch(() => null) - ]); + }).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 + }; + }) + ); + }); - const issues: IssueSummary[] = 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 closedCount = repo.then(async (r) => { + const repoDid = r.repoDid; + if (!repoDid) return 0; + return count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }) + .then((c) => c?.count ?? 0) + .catch(() => 0); }); return { state: state as "open" | "closed", - issues, - openCount: parent.counts.issues, - closedCount: closed?.count ?? 0 + 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 --- a/web/src/routes/[handle]/[repo]/pipelines/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte @@ -1,32 +1,41 @@ - - - - -
- {#if data.spindleError} - - {:else} - + + {@const repo = await data.repo} + + - {/if} -
+ + +
+ + {@const spindleError = await data.spindleError} + {#if spindleError} + + {:else} + + {/if} + +
+
diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.ts b/web/src/routes/[handle]/[repo]/pipelines/+page.ts --- a/web/src/routes/[handle]/[repo]/pipelines/+page.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.ts @@ -1,6 +1,7 @@ import { createSpindleClient, queryPipelines, toPipelineSummary } from "$lib/api/spindle"; import { createBobbinClient } from "$lib/api/client"; 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 type { Did } from "@atcute/lexicons"; @@ -14,75 +15,88 @@ export const load: PageLoad = async (event) => { const parent = await event.parent(); + const repo = Promise.resolve(parent.repo); 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 empty = { - filter, - query, - pipelines: [] as PipelineSummary[], - forkLabels: Promise.resolve({}), - total: 0, - // the setup steps only make sense on the plain unfiltered view, otherwise - // an empty result reads as "nothing matched" - filtered: filter !== "all" || query !== "", - spindleError: null as string | null - }; + const queryResult = repo.then(async (repo) => { + const { spindle, repoDid } = repo; + if (!spindle || !repoDid) return { page: null, spindleError: null }; - const { spindle, repoDid } = parent.repo; - - if (!spindle || !repoDid) return empty; - - const ctx = createSpindleClient(spindle, event.fetch); - - let page; - try { - page = await queryPipelines(ctx, { - repo: repoDid as Did, - kinds: filter === "all" ? undefined : [filter], - limit: PIPELINE_LIMIT - }); - } catch { - // a spindle that is down or slow shouldn't take out the tab, say so instead - return { ...empty, spindleError: `Could not reach ${spindle}` }; - } + const ctx = createSpindleClient(spindle, event.fetch); + try { + const page = await queryPipelines(ctx, { + repo: repoDid as Did, + kinds: filter === "all" ? undefined : [filter], + limit: PIPELINE_LIMIT + }); + return { page, spindleError: null }; + } catch { + return { page: null, spindleError: `Could not reach ${spindle}` }; + } + }); // the lexicon says pipelines is required, but the spindle sends null for an // empty result - const pipelines = (page.pipelines ?? []) - // a spindle answering for a different repo is misconfigured or lying - .filter((pipeline) => pipeline.repo === repoDid) - .map((pipeline) => toPipelineSummary(pipeline, repoDid)) - .filter((pipeline) => matchesQuery(pipeline, query)); + const pipelines = Promise.all([repo, queryResult]).then(([repo, result]) => { + const page = result.page; + if (!page || !repo.repoDid) return [] as PipelineSummary[]; + return ( + (page.pipelines ?? []) + // a spindle answering for a different repo is misconfigured or lying + .filter((pipeline) => pipeline.repo === repo.repoDid) + .map((pipeline) => toPipelineSummary(pipeline, repo.repoDid!)) + .filter((pipeline) => matchesQuery(pipeline, query)) + ); + }); - const forkDids = [ - ...new Set( - pipelines.flatMap((pipeline) => - pipeline.trigger.kind === "pull_request" && pipeline.trigger.sourceRepo - ? [pipeline.trigger.sourceRepo] - : [] - ) - ) - ]; // fork labels stream in behind the list, cards show a skeleton until bobbin // answers. a failed resolve leaves the bare branch - const forkLabels = - forkDids.length > 0 - ? resolveForkRepoLabels( - createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }), - forkDids - ).catch(() => ({})) - : Promise.resolve({}); + const forkLabels = pipelines + .then((list) => { + const forkDids = [ + ...new Set( + list.flatMap((pipeline) => + pipeline.trigger.kind === "pull_request" && pipeline.trigger.sourceRepo + ? [pipeline.trigger.sourceRepo] + : [] + ) + ) + ]; + return forkDids.length > 0 + ? resolveForkRepoLabels( + createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }), + forkDids + ).catch(() => ({})) + : {}; + }) + .catch(() => ({})); + + // 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 filtered = queryResult.then(({ page }) => { + if (!page) return filter !== "all" || query !== ""; + return page.total > 0 || filter !== "all" || query !== ""; + }); + + const spindleError = queryResult.then(({ spindleError }) => spindleError); return { filter, query, - pipelines, - forkLabels, - // the spindle counts every run matching the tab, the query narrows here - total: query === "" ? page.total : pipelines.length, - filtered: page.total > 0 || filter !== "all" || query !== "", - spindleError: null + pipelines: stream(pipelines), + forkLabels: stream(forkLabels), + total: stream(total), + filtered: stream(filtered), + spindleError: stream(spindleError) }; }; diff --git a/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts b/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts --- a/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts @@ -54,7 +54,7 @@ repo: { spindle: spindle ?? undefined, repoDid: REPO_DID, - ownerHandle3: "tangled.org", + ownerHandle: "tangled.org", name: "core" } }) @@ -74,7 +74,9 @@ const { event, fetchMock } = makeEvent({ spindle: null }); const data = await runLoad(event); expect(fetchMock).not.toHaveBeenCalled(); - expect(data).toMatchObject({ pipelines: [], total: 0, filtered: false, spindleError: null }); + expect(await data.pipelines).toEqual([]); + expect(await data.total).toBe(0); + expect(await data.filtered).toBe(false); }); it("maps a real push run and marks the plain view unfiltered", async () => { @@ -82,53 +84,56 @@ fetchMock.mockResolvedValue(jsonResponse({ pipelines: [pushPipeline()], total: 1 })); const data = await runLoad(event); - expect(data.pipelines).toHaveLength(1); - expect(data.pipelines[0]).toMatchObject({ + const pipelines = await data.pipelines; + expect(pipelines).toHaveLength(1); + expect(pipelines[0]).toMatchObject({ trigger: { kind: "push", targetRef: "master" }, workflows: [{ name: "build.yml", status: "success", duration: 120_000 }] }); - expect(data.total).toBe(1); + 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(data.filtered).toBe(true); + expect(await data.filtered).toBe(true); }); it("tolerates a null pipelines array, which is what the spindle sends when empty", async () => { const { event, fetchMock } = makeEvent(); fetchMock.mockResolvedValue(jsonResponse({ pipelines: null, total: 0 })); const data = await runLoad(event); - expect(data.pipelines).toEqual([]); + expect(await data.pipelines).toEqual([]); }); it("drops runs that answer for a different repo", async () => { const { event, fetchMock } = makeEvent(); fetchMock.mockResolvedValue( jsonResponse({ - pipelines: [pushPipeline(), pushPipeline({ id: "other", repo: "did:plc:someoneelse" })], + pipelines: [ + pushPipeline(), + pushPipeline({ id: "other", repo: "did:plc:someoneelse" }) + ], total: 2 }) ); const data = await runLoad(event); - expect(data.pipelines.map((p: PipelineSummary) => p.id)).toEqual(["3mrf4wqyxv22"]); + const pipelines = await data.pipelines; + expect(pipelines.map((p: PipelineSummary) => p.id)).toEqual(["3mrf4wqyxv22"]); }); it("narrows by the query and counts what survived, not the spindle total", async () => { const { event, fetchMock } = makeEvent({ search: "?q=nope" }); fetchMock.mockResolvedValue(jsonResponse({ pipelines: [pushPipeline()], total: 5 })); const data = await runLoad(event); - expect(data.pipelines).toEqual([]); - expect(data.total).toBe(0); - expect(data.filtered).toBe(true); + expect(await data.pipelines).toEqual([]); + expect(await data.total).toBe(0); + expect(await data.filtered).toBe(true); }); it("says so instead of throwing when the spindle is down", async () => { const { event, fetchMock } = makeEvent(); fetchMock.mockRejectedValue(new Error("connection refused")); const data = await runLoad(event); - expect(data).toMatchObject({ - pipelines: [], - spindleError: "Could not reach spindle.tangled.sh" - }); + expect(await data.pipelines).toEqual([]); + expect(await data.spindleError).toBe("Could not reach spindle.tangled.sh"); }); it("streams fork labels for pulls that came from another repo", async () => { @@ -176,7 +181,8 @@ ); const data = await runLoad(event); - expect(data.pipelines[0].trigger).toMatchObject({ sourceRepo: "did:plc:forkrepo" }); + const pipelines = await data.pipelines; + expect(pipelines[0].trigger).toMatchObject({ sourceRepo: "did:plc:forkrepo" }); await expect(data.forkLabels).resolves.toEqual({ "did:plc:forkrepo": "fork.example/core" }); // one call to the spindle, one to bobbin's enrich expect(fetchMock).toHaveBeenCalledTimes(2); @@ -202,7 +208,8 @@ ); const data = await runLoad(event); - expect(data.pipelines[0].trigger).not.toHaveProperty("sourceRepo"); + const pipelines = await data.pipelines; + expect(pipelines[0].trigger).not.toHaveProperty("sourceRepo"); await expect(data.forkLabels).resolves.toEqual({}); expect(fetchMock).toHaveBeenCalledTimes(1); }); diff --git a/web/src/routes/[handle]/[repo]/pulls/+page.svelte b/web/src/routes/[handle]/[repo]/pulls/+page.svelte --- a/web/src/routes/[handle]/[repo]/pulls/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/+page.svelte @@ -1,22 +1,33 @@ - - - + + + {@const repo = await data.repo} + + + -
- -
+
+ + + +
+
diff --git a/web/src/routes/[handle]/[repo]/pulls/+page.ts b/web/src/routes/[handle]/[repo]/pulls/+page.ts --- a/web/src/routes/[handle]/[repo]/pulls/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/+page.ts @@ -3,59 +3,68 @@ import { PULL_AUTHOR_DOCS } from "$lib/api/descriptors"; import { authorOf, enrich, target } from "$lib/api/enrich"; 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 type { PageLoad } from "./$types"; 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 repoDid = parent.repo.repoDid; + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); - // no repoDid means bobbin never indexed this repo, so there is nothing to list - if (!repoDid) { - return { - state, - pulls: [] as PullSummary[], - openCount: parent.counts.pulls, - mergedCount: 0, - closedCount: 0 - }; - } - - const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - - const [page, closed, merged] = await Promise.all([ - enrich(ctx, { + // returned unawaited so the page can paint its skeleton, ssr still waits for them + const pulls = repo.then(async (r) => { + const repoDid = r.repoDid; + if (!repoDid) return [] as PullSummary[]; + return enrich(ctx, { xrpc: "sh.tangled.repo.listPulls", params: { subject: repoDid, status: state }, enrich: [target(PULL_AUTHOR_DOCS, ["items[].uri"])] - }), - // the layout only knows the open count, the closed tab needs its own - count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "closed" }).catch(() => null), - count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "merged" }).catch(() => null) - ]); + }).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 + }; + }) + ); + }); - const pulls: PullSummary[] = 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 closedCount = repo.then(async (r) => { + const repoDid = r.repoDid; + if (!repoDid) return 0; + return count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "closed" }) + .then((c) => c?.count ?? 0) + .catch(() => 0); + }); + + const mergedCount = repo.then(async (r) => { + const repoDid = r.repoDid; + if (!repoDid) return 0; + return count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "merged" }) + .then((c) => c?.count ?? 0) + .catch(() => 0); }); return { state, - pulls, - openCount: parent.counts.pulls, - closedCount: closed?.count ?? 0, - mergedCount: merged?.count ?? 0 + pulls: stream(pulls), + openCount: stream(Promise.resolve(parent.counts).then((c) => c.pulls)), + closedCount: stream(closedCount), + mergedCount: stream(mergedCount) }; }; diff --git a/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts b/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts --- a/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts +++ b/web/src/routes/[handle]/[repo]/pulls/pulls.test.ts @@ -49,8 +49,13 @@ fetch: fetchMock, parent: async () => ({ publicConfig: { bobbinUrl: "https://bobbin.test" }, - repo: { repoDid: REPO_DID, ownerHandle: "alice.test", name: "core" }, - counts: { pulls: 1 } + repo: { + repoDid: REPO_DID, + ownerHandle: "alice.test", + name: "core", + defaultBranch: Promise.resolve("main") + }, + counts: Promise.resolve({ stars: 0, issues: 0, pulls: 1, forks: 0 }) }) } as never }; @@ -61,7 +66,8 @@ const { event, fetchMock } = makeEvent(); const data = (await load(event))!; - expect(data.pulls[0]).toMatchObject({ + const pulls = await data.pulls; + expect(pulls[0]).toMatchObject({ authorDid: AUTHOR_DID, authorHandle: "andrew.heiss.phd", state: "open" diff --git a/web/src/routes/[handle]/[repo]/settings/+layout.svelte b/web/src/routes/[handle]/[repo]/settings/+layout.svelte --- a/web/src/routes/[handle]/[repo]/settings/+layout.svelte +++ b/web/src/routes/[handle]/[repo]/settings/+layout.svelte @@ -1,5 +1,6 @@ - const items = $derived([ + + + {@const repo = await data.repo} + {@const base = `/${repo.ownerHandle}/${repo.name}/settings`} + {@const items: SettingsNavItem[] = [ { id: "general", label: "General", href: base, icon: SlidersHorizontal }, { id: "access", label: "Access", href: `${base}/access`, icon: UsersRound }, { id: "pipelines", label: "Pipelines", href: `${base}/pipelines`, icon: Layers }, { id: "hooks", label: "Hooks", href: `${base}/hooks`, icon: Webhook }, { id: "sites", label: "Sites", href: `${base}/sites`, icon: Globe } - ]); - - // route ids look like /[handle]/[repo]/settings//…, and a drill-down keeps - // its parent tab active, so only the segment right after `settings` matters - const segment = $derived(page.route.id?.split("/")[4] ?? ""); - const active = $derived(items.some((i) => i.id === segment) ? segment : "general"); - - - - {@render children()} - + ]} + {@const active = items.some((i) => i.id === segment) ? segment : "general"} + + {@render children()} + + diff --git a/web/src/routes/[handle]/[repo]/settings/+page.svelte b/web/src/routes/[handle]/[repo]/settings/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/+page.svelte @@ -1,9 +1,9 @@ -
-

General

- -
- -
-
- - - - - - - - - - - - - - - + + + + + + + + - - {#each labels as item (item.name)} - - {#snippet label()} - - + + + + diff --git a/web/src/routes/settings/keys/new/+page.svelte b/web/src/routes/settings/keys/new/+page.svelte --- a/web/src/routes/settings/keys/new/+page.svelte +++ b/web/src/routes/settings/keys/new/+page.svelte @@ -48,7 +48,13 @@ - diff --git a/web/src/routes/settings/profile/delete/+page.svelte b/web/src/routes/settings/profile/delete/+page.svelte --- a/web/src/routes/settings/profile/delete/+page.svelte +++ b/web/src/routes/settings/profile/delete/+page.svelte @@ -19,7 +19,11 @@ description="This permanently deletes your account and all associated data. This cannot be undone." > - + diff --git a/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte --- a/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte @@ -3,6 +3,7 @@ import { commitStatuses } from "$lib/api/commitStatuses"; import { toCommitDetail } from "$lib/api/repo"; import CommitView from "$lib/components/repo/CommitView.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import type { DiffStyle } from "$lib/components/repo/pierre"; let { data } = $props(); @@ -10,29 +11,29 @@ const diffStyle = $derived( page.url.searchParams.get("diff") === "split" ? "split" : "unified" ); - // the load guarantees a commit, the fallback only satisfies the type - const wire = $derived(data.commitDiff.commit ?? {}); - const commit = $derived(toCommitDetail(wire)); - const pipelineStatuses = $derived( - commitStatuses( - data.repo.spindle, - data.repo.ownerHandle, - data.repo.name, - commit.hash ? [commit.hash] : [] - ) - ); - + + {@const repo = await data.repo} + {@const commitDiff = await data.commitDiff} + {@const wire = commitDiff.commit ?? {}} + {@const commit = toCommitDetail(wire)} + + diff --git a/web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts --- a/web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts +++ b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.ts @@ -1,14 +1,14 @@ import { error } from "@sveltejs/kit"; import { browser } from "$app/environment"; import { ClientResponseError, createBobbinClient } from "$lib/api/client"; -import { diffFor, type DiffFile } from "$lib/api/diff"; -import { toHttpError } from "$lib/api/load"; +import { diffFor } from "$lib/api/diff"; +import type { DiffFile } from "$lib/api/diff"; +import { stream, toHttpError } from "$lib/api/load"; import { diffRowKey, toFileDiffMetadata } from "$lib/components/repo/fileDiff"; -import { pierreDiffOptions, type DiffStyle } from "$lib/components/repo/pierre"; +import { pierreDiffOptions } from "$lib/components/repo/pierre"; +import type { DiffStyle } from "$lib/components/repo/pierre"; import type { PageLoad } from "./$types"; -// the first paint ships highlighted: prerender each file's shadow dom on -// the server. skipped on client navigations, pierre paints those itself const prerenderDiffs = async ( files: DiffFile[], style: DiffStyle @@ -30,25 +30,33 @@ return Object.fromEntries(entries); }; -// the ref is a single encoded segment, `feature/x` arrives intact export const load: PageLoad = async (event) => { const parent = await event.parent(); + const repoPromise = Promise.resolve(parent.repo); const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - const repo = parent.repo.uri; - const result = await diffFor(ctx, repo, event.params.ref).catch((cause: unknown): never => { - if (cause instanceof ClientResponseError && cause.status === 404) { - error(404, "Commit not found"); - } - return toHttpError(cause, "Could not load commit"); + const diffPromise = repoPromise.then(async (repo) => { + const result = await diffFor(ctx, repo.uri, event.params.ref).catch( + (cause: unknown): never => { + if (cause instanceof ClientResponseError && cause.status === 404) { + error(404, "Commit not found"); + } + return toHttpError(cause, "Could not load commit"); + } + ); + if (!result?.diff?.commit) error(404, "Commit not found"); + return result.diff; }); - if (!result?.diff?.commit) error(404, "Commit not found"); const style = event.url.searchParams.get("diff") === "split" ? "split" : "unified"; - const prerendered = await prerenderDiffs(result.diff.diff ?? [], style); + const prerendered = diffPromise.then((diff) => prerenderDiffs(diff.diff ?? [], style)); - return { ref: event.params.ref, commitDiff: result.diff, prerendered }; + return { + ref: event.params.ref, + commitDiff: stream(diffPromise), + prerendered: stream(prerendered) + }; }; diff --git a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte --- a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte @@ -3,33 +3,38 @@ import { resolve } from "$app/paths"; import { commitStatuses } from "$lib/api/commitStatuses"; import CommitLogView from "$lib/components/repo/CommitLogView.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; let { data } = $props(); - const pipelineStatuses = $derived( - commitStatuses( - data.repo.spindle, - data.repo.ownerHandle, - data.repo.name, - data.commits.map((commit) => commit.hash) - ) - ); - - const pageHref = (page: number) => - resolve( - `/${data.repo.ownerHandle}/${data.repo.name}/commits/${encodeURIComponent(data.ref)}${page > 1 ? `?page=${page}` : ""}` as "/" - ); - const changePage = (page: number) => void goto(pageHref(page)); + const changePage = (page: number) => { + void Promise.resolve(data.repo).then((repo) => { + void goto( + resolve( + `/${repo.ownerHandle}/${repo.name}/commits/${encodeURIComponent(data.ref)}${page > 1 ? `?page=${page}` : ""}` as "/" + ) + ); + }); + }; - + + {@const repo = await data.repo} + {@const commits = await data.commits} + commit.hash) + )} + page={data.page} + pageCount={await data.pageCount} + onPageChange={changePage} + /> + diff --git a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts --- a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts +++ b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.ts @@ -1,53 +1,66 @@ -import { parallel } from "$lib/api/load"; import { branches, gitTarget, log, tags } from "$lib/api/gitclient"; import { REF_LIMIT } from "$lib/api/repoIndex"; import { tagsByCommitHash, toBranchSummary, toCommitSummary, toTagSummary } from "$lib/api/repo"; +import { stream } from "$lib/api/load"; import type { PageLoad } from "./$types"; -// same page size as the appview's log const COMMIT_LIMIT = 60; export const load: PageLoad = async (event) => { const parent = await event.parent(); - const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); + const repoPromise = Promise.resolve(parent.repo); const ref = event.params.ref; - // Number accepts hex/exponents/whitespace that the appview's Atoi rejects, - // gate first const rawPage = event.url.searchParams.get("page") ?? ""; const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; const page = parsed >= 1 ? parsed : 1; - // the log cursor is a numeric offset const cursor = page > 1 ? String((page - 1) * COMMIT_LIMIT) : undefined; - const results = await parallel({ - log: log(git, { ref, limit: COMMIT_LIMIT, cursor }), - tags: tags(git, REF_LIMIT), - branches: branches(git, REF_LIMIT) + const logPromise = repoPromise.then((repo) => { + const git = gitTarget(parent.publicConfig, repo, event.fetch); + return log(git, { ref, limit: COMMIT_LIMIT, cursor }); }); - const commits = (results.log.commits ?? []).map(toCommitSummary); - const totalCommits = results.log.total ?? 0; - // knot2 answers an exact total when it can, without one a full page hints - // at another - const pageCount = - totalCommits > 0 - ? Math.ceil(totalCommits / COMMIT_LIMIT) - : page + (commits.length === COMMIT_LIMIT ? 1 : 0); + const commits = logPromise.then((res) => (res.commits ?? []).map(toCommitSummary)); + const totalCommits = logPromise.then((res) => res.total ?? 0); + const pageCount = logPromise.then((res) => { + const total = res.total ?? 0; + const count = res.commits?.length ?? 0; + return total > 0 + ? Math.ceil(total / COMMIT_LIMIT) + : page + (count === COMMIT_LIMIT ? 1 : 0); + }); - const tagsByCommit = tagsByCommitHash(commits, (results.tags.tags ?? []).map(toTagSummary)); - // branch tips go into the same badge map as tags - const shown = new Set(commits.map((commit) => commit.hash)); - for (const branch of (results.branches.branches ?? []).map(toBranchSummary)) { - if (shown.has(branch.hash)) (tagsByCommit[branch.hash] ??= []).push(branch.name); - } + const tagsPromise = repoPromise + .then((repo) => { + const git = gitTarget(parent.publicConfig, repo, event.fetch); + return tags(git, REF_LIMIT); + }) + .catch(() => ({ tags: [] })); + const branchesPromise = repoPromise + .then((repo) => { + const git = gitTarget(parent.publicConfig, repo, event.fetch); + return branches(git, REF_LIMIT); + }) + .catch(() => ({ branches: [] })); + + const tagsByCommit = Promise.all([commits, tagsPromise, branchesPromise]).then( + ([commitList, tagRes, branchRes]) => { + const map = tagsByCommitHash(commitList, (tagRes.tags ?? []).map(toTagSummary)); + const shown = new Set(commitList.map((commit) => commit.hash)); + for (const branch of (branchRes.branches ?? []).map(toBranchSummary)) { + if (shown.has(branch.hash)) (map[branch.hash] ??= []).push(branch.name); + } + return map; + } + ); return { ref, page, - pageCount, - commits, - totalCommits, - tagsByCommit + pageCount: stream(pageCount), + commits: stream(commits), + totalCommits: stream(totalCommits), + tagsByCommit: stream(tagsByCommit) }; }; diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte @@ -1,195 +1,25 @@ - - {issue.title} · Issue #{issue.rkey} · {data.repo.ownerHandle}/{data.repo - .name} - · Tangled - - -{#snippet issueActions()} - - -{/snippet} - -
- -
- - {#if editing} - (editing = false)} - /> - {:else} - - - - - {#if removeIssue.error} -
- -
- {/if} - {/if} -
- -
- - - {#if auth.currentUser} - - {:else} - - {/if} -
-
- - -
- -
-
+ + {@const repo = await data.repo} + {@const result = await data.page} + {#if result.kind === "ok"} + + {:else if result.kind === "not-found"} + + {:else if result.kind === "error"} + + {/if} + diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts @@ -1,5 +1,6 @@ -import { error } from "@sveltejs/kit"; -import { createBobbinClient } from "$lib/api/client"; +import { browser } from "$app/environment"; +import { error, isHttpError } from "@sveltejs/kit"; +import { createBobbinClient, ClientResponseError, type BobbinContext } from "$lib/api/client"; import { COMMENT_AUTHOR_DOCS, ISSUE_AUTHOR_DOCS, REACTION_AUTHOR_DOCS } from "$lib/api/descriptors"; import { authorOf, enrich, miniDocOf, target } from "$lib/api/enrich"; import { INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; @@ -8,10 +9,182 @@ import type { ReactionListPage, ReactionRecord } from "$lib/api/reaction"; import type { RecordView } from "$lib/api/records"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; +import { httpStatusFor, stream } from "$lib/api/load"; import { renderMarkup } from "$lib/markup"; -import { buildCommentThreads, type ThreadInput } from "$lib/components/comment/comments"; +import type { MarkupContext } from "$lib/markup/paths"; +import { + buildCommentThreads, + type CommentThread, + type ThreadInput +} from "$lib/components/comment/comments"; import { buildReactions, type ReactionGroup } from "$lib/components/reaction/reactions"; +import type { IssueData } from "$lib/components/repo/issues/IssueThreadView.svelte"; +import type { RepoInfo } from "$lib/components/repo/types"; import type { PageLoad } from "./$types"; + +export type { IssueData }; + +export type IssueThreadResult = + | { + kind: "ok"; + issue: IssueData; + comments: CommentThread[]; + markup: MarkupContext; + } + | { + kind: "not-found"; + } + | { + kind: "error"; + message: string; + }; + +const loadThread = async ( + ctx: BobbinContext, + uri: string, + repoPromise: Promise, + host: string, + viewerDid: string | undefined +): Promise => { + try { + const issuePromise = enrich>(ctx, { + xrpc: "sh.tangled.repo.getIssue", + params: { issue: uri }, + enrich: [target(ISSUE_AUTHOR_DOCS, ["uri"])] + }).catch((cause: unknown) => { + if ( + cause instanceof ClientResponseError && + (cause.status === 404 || httpStatusFor(cause) === 404) + ) { + return null; + } + throw cause; + }); + + const [issuePage, resolvedRepo] = await Promise.all([issuePromise, repoPromise]); + if (!issuePage) return { kind: "not-found" }; + + const ref = (await resolvedRepo.defaultBranch) ?? "main"; + const markupOpts: MarkupContext = { + repo: `${resolvedRepo.ownerHandle}/${resolvedRepo.name}`, + ref, + host + }; + const record = issuePage.output; + const author = authorOf(issuePage.data, record.uri, ISSUE_AUTHOR_DOCS); + + const [states, comments] = await Promise.all([ + listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null), + enrich(ctx, { + xrpc: "sh.tangled.feed.listComments", + params: { subject: record.uri, order: "asc", limit: 100 }, + enrich: [target(COMMENT_AUTHOR_DOCS, ["items[].uri"])] + }).catch(() => null) + ]); + + const latest = states?.items?.[0]?.value?.state; + const state = latest?.endsWith(".closed") ? ("closed" as const) : ("open" as const); + const body = record.value.body ?? ""; + const bodyHtml = body ? await renderMarkup(body, markupOpts) : null; + const commentItems = comments?.output?.items ?? []; + + const subjects = [record.uri, ...commentItems.map((item) => item.uri)]; + const reactionPages = await Promise.all( + subjects.map((subject) => + enrich(ctx, { + xrpc: "sh.tangled.feed.listReactions", + params: { subject, order: "asc", limit: 100 }, + enrich: [target(REACTION_AUTHOR_DOCS, ["items[].uri"])] + }).catch(() => null) + ) + ); + const reactionsBySubject = new Map[]>(); + subjects.forEach((subject, i) => + reactionsBySubject.set(subject, reactionPages[i]?.output?.items ?? []) + ); + + const reactorDids = new Set(); + for (const items of reactionsBySubject.values()) { + for (const item of items) reactorDids.add(didFromUri(item.uri)); + } + + const reactorDoc = (did: string): MiniDoc | undefined => { + for (const page of reactionPages) { + const doc = page && miniDocOf(page.data, did, REACTION_AUTHOR_DOCS); + if (doc) return doc; + } + return undefined; + }; + const reactorHandles = new Map( + [...reactorDids].map((did) => [did, reactorDoc(did)?.handle ?? INVALID_HANDLE] as const) + ); + const reactionsFor = (subject: string): ReactionGroup[] => + buildReactions( + reactionsBySubject.get(subject) ?? [], + viewerDid, + (did) => reactorHandles.get(did) ?? INVALID_HANDLE + ); + + const threadInputs: ThreadInput[] = await Promise.all( + commentItems.map(async (item): Promise => { + const commentAuthor = authorOf(comments?.data ?? {}, item.uri, COMMENT_AUTHOR_DOCS); + const commentBody = item.value.body?.text ?? ""; + const commentBodyHtml = commentBody + ? await renderMarkup(commentBody, markupOpts) + : null; + return { + comment: { + uri: item.uri, + cid: item.cid, + rkey: rkeyFromUri(item.uri), + authorDid: commentAuthor.did, + authorHandle: commentAuthor.handle ?? INVALID_HANDLE, + createdAt: item.value.createdAt, + body: commentBody, + bodyHtml: commentBodyHtml, + reactions: reactionsFor(item.uri) + }, + replyTo: item.value.replyTo?.uri ?? null, + replyToCid: item.value.replyTo?.cid + }; + }) + ); + + return { + kind: "ok", + issue: { + uri: record.uri, + cid: record.cid, + rkey: rkeyFromUri(record.uri), + title: record.value.title, + body, + bodyHtml, + state, + authorDid: author.did, + authorHandle: author.handle ?? INVALID_HANDLE, + createdAt: record.value.createdAt, + reactions: reactionsFor(record.uri) + }, + comments: buildCommentThreads(threadInputs), + markup: markupOpts + }; + } catch (cause) { + if (isHttpError(cause)) throw cause; + if ( + cause instanceof ClientResponseError && + (cause.status === 404 || httpStatusFor(cause) === 404) + ) { + return { kind: "not-found" }; + } + const message = + cause instanceof ClientResponseError + ? (cause.description ?? cause.error) + : cause instanceof Error + ? cause.message + : "Could not load issue"; + return { kind: "error", message }; + } +}; export const load: PageLoad = async (event) => { const parent = await event.parent(); @@ -19,118 +192,18 @@ if (!uri.startsWith("at://")) error(404, "Issue not found"); - const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - - const issuePage = await enrich>(ctx, { - xrpc: "sh.tangled.repo.getIssue", - params: { issue: uri }, - enrich: [target(ISSUE_AUTHOR_DOCS, ["uri"])] - }).catch(() => null); - if (!issuePage) error(404, "Issue not found"); - - const record = issuePage.output; - const author = authorOf(issuePage.data, record.uri, ISSUE_AUTHOR_DOCS); - const markupOpts = { - repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, - ref: parent.repo.defaultBranch, - host: event.url.host - }; - - const [states, comments] = await Promise.all([ - listIssueStates(ctx, record.uri, { limit: 1, order: "desc" }).catch(() => null), - enrich(ctx, { - xrpc: "sh.tangled.feed.listComments", - params: { subject: record.uri, order: "asc", limit: 100 }, - enrich: [target(COMMENT_AUTHOR_DOCS, ["items[].uri"])] - }).catch(() => null) - ]); - - const latest = states?.items[0]?.value.state; - const state = latest?.endsWith(".closed") ? ("closed" as const) : ("open" as const); - const body = record.value.body ?? ""; - const bodyHtml = body ? await renderMarkup(body, markupOpts) : null; + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); const viewerDid = parent.auth?.did; - const commentItems = comments?.output.items ?? []; - // reactions: one listReactions per subject (the issue itself + each comment). - // bobbin has no batch-by-subject query and enrich can't break counts down by - // emoji, so fan out and group client-side. reactor docs ride along in the - // same enrich calls - const subjects = [record.uri, ...commentItems.map((item) => item.uri)]; - const reactionPages = await Promise.all( - subjects.map((subject) => - enrich(ctx, { - xrpc: "sh.tangled.feed.listReactions", - params: { subject, order: "asc", limit: 100 }, - enrich: [target(REACTION_AUTHOR_DOCS, ["items[].uri"])] - }).catch(() => null) - ) - ); - const reactionsBySubject = new Map[]>(); - subjects.forEach((subject, i) => - reactionsBySubject.set(subject, reactionPages[i]?.output.items ?? []) - ); - - const reactorDids = new Set(); - for (const items of reactionsBySubject.values()) { - for (const item of items) reactorDids.add(didFromUri(item.uri)); + const page = loadThread(ctx, uri, Promise.resolve(parent.repo), event.url.host, viewerDid); + if (!browser) { + const resolved = await page; + if (resolved.kind === "not-found") error(404, "Issue not found"); + if (resolved.kind === "error") error(500, resolved.message); } - // a reactor's doc can land in any subject's sidecar - const reactorDoc = (did: string): MiniDoc | undefined => { - for (const page of reactionPages) { - const doc = page && miniDocOf(page.data, did, REACTION_AUTHOR_DOCS); - if (doc) return doc; - } - return undefined; - }; - const reactorHandles = new Map( - [...reactorDids].map((did) => [did, reactorDoc(did)?.handle ?? INVALID_HANDLE] as const) - ); - const reactionsFor = (subject: string): ReactionGroup[] => - buildReactions( - reactionsBySubject.get(subject) ?? [], - viewerDid, - (did) => reactorHandles.get(did) ?? INVALID_HANDLE - ); - const threadInputs: ThreadInput[] = await Promise.all( - commentItems.map(async (item): Promise => { - const commentAuthor = authorOf(comments?.data ?? {}, item.uri, COMMENT_AUTHOR_DOCS); - const commentBody = item.value.body?.text ?? ""; - const commentBodyHtml = commentBody ? await renderMarkup(commentBody, markupOpts) : null; - return { - comment: { - uri: item.uri, - cid: item.cid, - rkey: rkeyFromUri(item.uri), - authorDid: commentAuthor.did, - authorHandle: commentAuthor.handle, - createdAt: item.value.createdAt, - body: commentBody, - bodyHtml: commentBodyHtml, - reactions: reactionsFor(item.uri) - }, - replyTo: item.value.replyTo?.uri ?? null, - replyToCid: item.value.replyTo?.cid - }; - }) - ); - - return { - issue: { - uri: record.uri, - cid: record.cid, - rkey: rkeyFromUri(record.uri), - title: record.value.title, - body, - bodyHtml, - state, - authorDid: author.did, - authorHandle: author.handle, - createdAt: record.value.createdAt, - reactions: reactionsFor(record.uri) - }, - comments: buildCommentThreads(threadInputs), - markup: markupOpts - }; + return { page: stream(page) }; }; diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/issue.test.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/issue.test.ts new file mode 100644 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/issue.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { load } from "./+page"; +import { ISSUE_AUTHOR_DOCS } from "$lib/api/descriptors"; +import { TYPE_MINIDOC } from "$lib/api/enrich"; + +const REPO_DID = "did:plc:j5hmlfdrwkvtxm7cjmu7j2is"; +const AUTHOR_DID = "did:plc:2zcfjzyocp6kapg6jc4eacok"; +const ISSUE_URI = `at://${AUTHOR_DID}/sh.tangled.repo.issue/3mgftgdw6ad22`; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); + +const issueResponse = () => + jsonResponse({ + output: { + uri: ISSUE_URI, + cid: "bafyreidakrovoxr2yfueukylm44kthte5g3usyex27e5cqv3nkze7tfxb4", + value: { + title: "bug: login fails", + body: "steps to reproduce", + createdAt: "2026-08-04T09:00:00.000Z" + } + }, + data: { + [AUTHOR_DID]: { + [ISSUE_AUTHOR_DOCS.source]: { + [TYPE_MINIDOC]: { did: AUTHOR_DID, handle: "andrew.heiss.phd" } + } + } + } + }); + +const makeEvent = (fetchOverride?: typeof fetch) => { + const fetchMock = + fetchOverride ?? + vi.fn().mockImplementation(async (input, init) => { + const str = String(input); + const bodyStr = init?.body ? String(init.body) : ""; + if (bodyStr.includes("getIssue") || str.includes("getIssue")) return issueResponse(); + if (str.includes("listIssueStates")) return jsonResponse({ items: [] }); + if (bodyStr.includes("listComments") || str.includes("listComments")) + return jsonResponse({ output: { items: [] }, data: {} }); + if (bodyStr.includes("listReactions") || str.includes("listReactions")) + return jsonResponse({ output: { items: [] }, data: {} }); + return jsonResponse({ output: { items: [] }, data: {} }); + }); + + return { + fetchMock, + event: { + url: new URL("http://web.test/alice.test/core/issues/" + encodeURIComponent(ISSUE_URI)), + params: { + handle: "alice.test", + repo: "core", + aturi: ISSUE_URI + }, + fetch: fetchMock, + parent: async () => ({ + publicConfig: { bobbinUrl: "https://bobbin.test" }, + repo: { + repoDid: REPO_DID, + ownerHandle: "alice.test", + name: "core", + defaultBranch: Promise.resolve("main") + }, + counts: Promise.resolve({ stars: 0, issues: 1, pulls: 0, forks: 0 }), + auth: { did: AUTHOR_DID } + }) + } as never + }; +}; + +describe("issue detail load", () => { + it("returns streamed page promise that resolves to ok kind", async () => { + const { event } = makeEvent(); + const data = (await load(event))!; + + expect(data.page).toBeInstanceOf(Promise); + const result = await data.page; + if (result.kind === "error") { + throw new Error(`got error: ${result.message}`); + } + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.issue).toMatchObject({ + uri: ISSUE_URI, + title: "bug: login fails", + authorDid: AUTHOR_DID, + authorHandle: "andrew.heiss.phd", + state: "open" + }); + expect(result.comments).toEqual([]); + } + }); + + it("returns 404 when the issue is missing", async () => { + const fetchMock = vi.fn().mockImplementation(async () => { + return jsonResponse({ error: "RecordNotFound", message: "no such issue" }, 404); + }); + const { event } = makeEvent(fetchMock); + + await expect(load(event)).rejects.toMatchObject({ status: 404 }); + }); + + it("returns 500 when the issue load fails", async () => { + const fetchMock = vi.fn().mockImplementation(async () => { + return jsonResponse({ error: "InternalError", message: "db down" }, 500); + }); + const { event } = makeEvent(fetchMock); + + await expect(load(event)).rejects.toMatchObject({ + status: 500, + body: { message: "db down" } + }); + }); +}); diff --git a/web/src/routes/[handle]/[repo]/issues/new/+page.svelte b/web/src/routes/[handle]/[repo]/issues/new/+page.svelte --- a/web/src/routes/[handle]/[repo]/issues/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/new/+page.svelte @@ -3,39 +3,55 @@ import { page } from "$app/state"; import { resolve } from "$app/paths"; import IssueForm from "$lib/components/repo/issues/IssueForm.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; let { data } = $props(); - const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/issues`); - - const markup = $derived({ - repo: `${data.repo.ownerHandle}/${data.repo.name}`, - ref: data.repo.defaultBranch, - host: page.url.host, - camo: data.publicConfig?.camoEnabled + let defaultBranch = $state("main"); + $effect(() => { + void Promise.resolve(data.repo) + .then((r) => r.defaultBranch) + .then((b) => { + defaultBranch = b; + }); }); // no issue detail route yet, so land back on the list after creating const handleSaved = async () => { - await goto(resolve(base as "/")); + const repo = await data.repo; + await goto(resolve(`/${repo.ownerHandle}/${repo.name}/issues` as "/")); }; - const handleCancel = () => { - void goto(resolve(base as "/")); + const handleCancel = async () => { + const repo = await data.repo; + void goto(resolve(`/${repo.ownerHandle}/${repo.name}/issues` as "/")); }; - New issue · {data.repo.ownerHandle}/{data.repo.name} · Tangled + New issue · {decodeURIComponent(page.params.handle ?? "")}/{decodeURIComponent( + page.params.repo ?? "" + )} · Tangled

Create a new issue

- + + {@const repo = await data.repo} + {@const markup = { + repo: `${repo.ownerHandle}/${repo.name}`, + ref: defaultBranch, + host: page.url.host, + camo: data.publicConfig?.camoEnabled + }} + +
diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts b/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts @@ -11,10 +11,12 @@ export const load: LayoutLoad = async (event) => { const parent = await event.parent(); + const repo = await parent.repo; + const defaultBranch = await repo.defaultBranch; const uri = event.params.aturi; if (!uri.startsWith("at://")) error(404, "Pull request not found"); - const repoDid = parent.repo.repoDid; + const repoDid = repo.repoDid; if (!repoDid) error(404, "Pull request not found"); const ctx = createBobbinClient({ @@ -23,8 +25,8 @@ }); const markup: MarkupContext = { - repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, - ref: parent.repo.defaultBranch, + repo: `${repo.ownerHandle}/${repo.name}`, + ref: defaultBranch, host: event.url.host }; @@ -65,6 +67,7 @@ ); return { + repo, comments, markup, sourceRepoDid: pullView.source?.repo?.did ?? repoDid, diff --git a/web/src/routes/[handle]/[repo]/pulls/new/+page.svelte b/web/src/routes/[handle]/[repo]/pulls/new/+page.svelte --- a/web/src/routes/[handle]/[repo]/pulls/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/new/+page.svelte @@ -1,31 +1,48 @@ - New pull · {data.repo.ownerHandle}/{data.repo.name} · Tangled + New pull · {decodeURIComponent(page.params.handle ?? "")}/{decodeURIComponent( + page.params.repo ?? "" + )} · Tangled - + + {@const repo = await data.repo} + {@const compose = await data.compose} + {@const markup = { + repo: `${repo.ownerHandle}/${repo.name}`, + ref: defaultBranch, + host: page.url.host, + camo: data.publicConfig?.camoEnabled + }} + + diff --git a/web/src/routes/[handle]/[repo]/pulls/new/+page.ts b/web/src/routes/[handle]/[repo]/pulls/new/+page.ts --- a/web/src/routes/[handle]/[repo]/pulls/new/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/new/+page.ts @@ -1,16 +1,20 @@ -import { error } from "@sveltejs/kit"; +import { stream } from "$lib/api/load"; import { loadCompose } from "$lib/api/pullCompose"; import type { PageLoad } from "./$types"; export const load: PageLoad = async (event) => { const parent = await event.parent(); - if (!parent.repo.repoDid) error(404, "This repository has not been indexed yet"); + const repo = Promise.resolve(parent.repo); - return loadCompose({ - config: parent.publicConfig, - repo: parent.repo, - viewer: parent.auth, - params: event.url.searchParams, - fetch: event.fetch - }); + return { + compose: stream( + loadCompose({ + config: parent.publicConfig, + repo, + viewer: parent.auth, + params: event.url.searchParams, + fetch: event.fetch + }) + ) + }; }; diff --git a/web/src/routes/[handle]/[repo]/settings/access/+page.svelte b/web/src/routes/[handle]/[repo]/settings/access/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/access/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/access/+page.svelte @@ -1,8 +1,8 @@ -
-

Access

-
+ + + {@const repo = await data.repo} + {@const base = `/${repo.ownerHandle}/${repo.name}/settings`} +
+

Access

+
- - {#snippet action()} - - {/snippet} + + {#snippet action()} + + {/snippet} - - {#each collaborators as person (person.handle)} - - {#snippet label()} - - - - {person.handle} + + {#each collaborators as person (person.handle)} + + {#snippet label()} + + + + {person.handle} + + {person.role} - {person.role} - - {/snippet} - {#if !person.owner} - - {/if} - - {/each} - - + {/snippet} + {#if !person.owner} + + {/if} +
+ {/each} +
+ + diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte @@ -1,8 +1,8 @@ -
-

Hooks

-
+ + + {@const repo = await data.repo} + {@const base = `/${repo.ownerHandle}/${repo.name}/settings`} +
+

Hooks

+
- - {#snippet action()} - - {/snippet} + + {#snippet action()} + + {/snippet} - {#if hooks.length === 0} - - {:else} - - {#each hooks as hook (hook.id)} -
-
- {hook.url} - - - - {hook.added} - - {hook.by} - -
-
- -
- - - + {#if hooks.length === 0} + + {:else} + + {#each hooks as hook (hook.id)} +
+
+ {hook.url} + + + + {hook.added} + + {hook.by} + +
+
+ +
+ + + +
-
- {/each} - - {/if} - + {/each} + + {/if} + + diff --git a/web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte b/web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte @@ -1,8 +1,8 @@ -
-

Pipelines

-
+ + + {@const repo = await data.repo} + {@const base = `/${repo.ownerHandle}/${repo.name}/settings`} +
+

Pipelines

+
- - {#snippet action()} - - {/snippet} + + {#snippet action()} + + {/snippet} - - - ({ value: option }))} + label="Spindle" + class="w-full sm:w-80" + /> - {/each} - - + + + + + {#snippet action()} + + {/snippet} + + + {#each secrets as secret (secret.name)} + + {#snippet label()} + + + {secret.name} + + + {secret.added} + + {secret.by} + + + {/snippet} + + + {/each} + + +
diff --git a/web/src/routes/[handle]/[repo]/settings/rename/+page.svelte b/web/src/routes/[handle]/[repo]/settings/rename/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/rename/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/rename/+page.svelte @@ -3,6 +3,7 @@ import { resolve } from "$app/paths"; import Button from "$lib/components/ui/Button.svelte"; import Input from "$lib/components/ui/Input.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import DrillDown from "$lib/components/settings/DrillDown.svelte"; import SettingsList from "$lib/components/settings/SettingsList.svelte"; import FormRow from "$lib/components/settings/FormRow.svelte"; @@ -15,60 +16,68 @@ let { data } = $props(); - const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); - const did = $derived(data.repo.ownerDid); - - // the point of the screen: these survive a rename, the handle-based ones don't - const stableUrls = $derived([`https://tangled.org/${did}`, `git@tangled.org:${did}`]); - const copyFeedback = createCopyFeedback(); let name = $state(""); - const valid = $derived(name.trim().length > 0 && name.trim() !== data.repo.name); // mocked — renaming is not wired up yet - const submit = () => goto(resolve(back as "/")); + const submit = () => { + void Promise.resolve(data.repo).then((repo) => { + void goto(resolve(`/${repo.ownerHandle}/${repo.name}/settings` as "/")); + }); + }; - -
- {#each stableUrls as url (url)} -
- - {url} - - -
- {/each} -
+ + + {@const repo = await data.repo} + {@const back = `/${repo.ownerHandle}/${repo.name}/settings`} + {@const did = repo.ownerDid} + + {@const stableUrls = [`https://tangled.org/${did}`, `git@tangled.org:${did}`]} + {@const valid = name.trim().length > 0 && name.trim() !== repo.name} + +
+ {#each stableUrls as url (url)} +
+ + {url} + + +
+ {/each} +
- - - - - + + + + + - - - - -
+ + + + +
+ diff --git a/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte b/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte @@ -1,8 +1,7 @@ -
-

Sites

- -
+ + + {@const repo = await data.repo} + {@const subPath = `${domain}/${repo.name}`} +
+

Sites

+ +
-
-

- Serve a static site directly from this repository. Choose a branch and the directory containing - your index.html.
- Only repository owners can configure sites. -

+
+

+ Serve a static site directly from this repository. Choose a branch and the directory + containing your index.html.
+ Only repository owners can configure sites. +

- {#if deployed} -
- Live at {domain} - -
- {/if} -
- - - - - - - -
-
- Index site - {domain} + {#if deployed} +
+ Live at {domain} +
-
- Sub-path site - {subPath} -
-
- + {/if} +
- {#if deployed} + - + + - 0}> - {#if deploys.length === 0} - - {:else} - {#each deploys as deploy (deploy.branch)} - - {#snippet label()} - - - {deploy.branch} - - {deploy.status} - {deploy.reason} - - {/snippet} - {deploy.when} + +
+
+ Index site + {domain} +
+
+ Sub-path site + {subPath} +
+
+
+ + {#if deployed} + + - {/each} - {/if} -
+ {/if} +
+ +
+ + 0}> + {#if deploys.length === 0} + + {:else} + {#each deploys as deploy (deploy.branch)} + + {#snippet label()} + + + {deploy.branch} + + {deploy.status} + {deploy.reason} + + {/snippet} + {deploy.when} + + {/each} + {/if} + + diff --git a/web/src/routes/[handle]/[repo]/tags/[tag]/+page.svelte b/web/src/routes/[handle]/[repo]/tags/[tag]/+page.svelte --- a/web/src/routes/[handle]/[repo]/tags/[tag]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/tags/[tag]/+page.svelte @@ -1,11 +1,15 @@
-
- -
+ + {@const repo = await data.repo} +
+ +
+
diff --git a/web/src/routes/[handle]/[repo]/tags/[tag]/+page.ts b/web/src/routes/[handle]/[repo]/tags/[tag]/+page.ts --- a/web/src/routes/[handle]/[repo]/tags/[tag]/+page.ts +++ b/web/src/routes/[handle]/[repo]/tags/[tag]/+page.ts @@ -1,21 +1,26 @@ import { error } from "@sveltejs/kit"; import { gitTarget, tag, tags } from "$lib/api/gitclient"; +import { stream } from "$lib/api/load"; import { toTagSummary } from "$lib/api/repo"; import type { PageLoad } from "./$types"; export const load: PageLoad = async (event) => { const parent = await event.parent(); - const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); + const repoPromise = Promise.resolve(parent.repo); // the params arrive decoded, the wire names are raw const name = event.params.tag; - let entry = await tag(git, name) - .then((result) => result.tag) - .catch(() => undefined); - if (!entry && name === "latest") { - entry = await tags(git, 1) - .then((results) => results.tags?.[0]) + const tagPromise = repoPromise.then(async (repo) => { + const git = gitTarget(parent.publicConfig, repo, event.fetch); + let entry = await tag(git, name) + .then((result) => result.tag) .catch(() => undefined); - } - if (!entry) error(404, `no tag named ${name}`); - return { tag: toTagSummary(entry) }; + if (!entry && name === "latest") { + entry = await tags(git, 1) + .then((results) => results.tags?.[0]) + .catch(() => undefined); + } + if (!entry) error(404, `no tag named ${name}`); + return toTagSummary(entry); + }); + return { tag: stream(tagPromise) }; }; diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte --- a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte @@ -1,17 +1,23 @@ - + + {@const repo = await data.repo} + + diff --git a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte --- a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.svelte @@ -1,14 +1,17 @@ - + + {@const repo = await data.repo} + {@const blob = await data.blob} + + diff --git a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts --- a/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts +++ b/web/src/routes/[handle]/[repo]/blob/[ref]/[...path]/+page.ts @@ -1,9 +1,14 @@ import { error } from "@sveltejs/kit"; import { browser } from "$app/environment"; -import { hasTextView, loadRepoBlob } from "$lib/api/blob"; +import { hasTextView, loadRepoBlob, type RepoBlobView } from "$lib/api/blob"; +import { stream } from "$lib/api/load"; import { pierreFileOptions } from "$lib/components/repo/pierre"; import { baseName } from "$lib/components/repo/urls"; import type { PageLoad } from "./$types"; + +export interface BlobContent extends RepoBlobView { + prerenderedHTML?: string; +} // the ref is a single encoded segment, `feature/x` arrives intact. the // rest param is already the path as git knows it @@ -11,25 +16,31 @@ // no path means the knot would 400 on the empty path, 404 instead if (event.params.path === "") error(404, "Not found"); const parent = await event.parent(); - const blob = await loadRepoBlob(event, parent, event.params.ref, event.params.path); - // same deal as the commit page: prerender the code view's shadow dom on - // the server, pierre paints client navigations itself - let prerenderedHTML: string | undefined; - if ( - !browser && - blob.contents !== null && - !blob.fileTooLarge && - hasTextView(blob.kind) && - blob.defaultView === "code" - ) { - const { preloadFile } = await import("@pierre/diffs/ssr"); - const prerendered = await preloadFile({ - file: { name: baseName(blob.path), contents: blob.contents }, - options: pierreFileOptions(false) - }); - prerenderedHTML = prerendered.prerenderedHTML; - } + const blobPromise = loadRepoBlob(event, parent, event.params.ref, event.params.path).then( + async (blob): Promise => { + let prerenderedHTML: string | undefined; + if ( + !browser && + blob.contents !== null && + !blob.fileTooLarge && + hasTextView(blob.kind) && + blob.defaultView === "code" + ) { + const { preloadFile } = await import("@pierre/diffs/ssr"); + const prerendered = await preloadFile({ + file: { name: baseName(blob.path), contents: blob.contents }, + options: pierreFileOptions(false) + }); + prerenderedHTML = prerendered.prerenderedHTML; + } + return { ...blob, prerenderedHTML }; + } + ); - return { ...blob, prerenderedHTML }; + return { + ref: event.params.ref, + path: event.params.path, + blob: stream(blobPromise) + }; }; diff --git a/web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte b/web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte @@ -3,6 +3,7 @@ import { resolve } from "$app/paths"; import Button from "$lib/components/ui/Button.svelte"; import Input from "$lib/components/ui/Input.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import DrillDown from "$lib/components/settings/DrillDown.svelte"; import SettingsList from "$lib/components/settings/SettingsList.svelte"; import FormRow from "$lib/components/settings/FormRow.svelte"; @@ -12,29 +13,36 @@ let { data } = $props(); - const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings/access`); - let user = $state(""); const valid = $derived(user.trim().length > 0); // mocked — collaborator management is not wired up yet - const submit = () => goto(resolve(back as "/")); + const submit = () => { + void Promise.resolve(data.repo).then((repo) => { + void goto(resolve(`/${repo.ownerHandle}/${repo.name}/settings/access` as "/")); + }); + }; - - - - - - + + + {@const repo = await data.repo} + {@const back = `/${repo.ownerHandle}/${repo.name}/settings/access`} + + + + + + - - - - - + + + + + + diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte @@ -4,6 +4,7 @@ import Button from "$lib/components/ui/Button.svelte"; import Input from "$lib/components/ui/Input.svelte"; import Checkbox from "$lib/components/ui/Checkbox.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import DrillDown from "$lib/components/settings/DrillDown.svelte"; import SettingsList from "$lib/components/settings/SettingsList.svelte"; import FormRow from "$lib/components/settings/FormRow.svelte"; @@ -12,8 +13,6 @@ import X from "$icon/x"; let { data } = $props(); - - const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings/hooks`); let url = $state(""); let secret = $state(""); @@ -39,43 +38,56 @@ ]; const valid = $derived(url.trim().length > 0 && Object.values(events).some(Boolean)); - const submit = () => goto(resolve(back as "/")); + const submit = () => { + void Promise.resolve(data.repo).then((repo) => { + void goto(resolve(`/${repo.ownerHandle}/${repo.name}/settings/hooks` as "/")); + }); + }; - - - - - + + + {@const repo = await data.repo} + {@const back = `/${repo.ownerHandle}/${repo.name}/settings/hooks`} + + + + + - - - + + + - -
- {#each EVENTS as event (event.id)} - {event.label} - {/each} -
-
-
+ +
+ {#each EVENTS as event (event.id)} + {event.label} + {/each} +
+
+
- - - - -
+ + + + + + diff --git a/web/src/routes/[handle]/[repo]/settings/labels/new/+page.svelte b/web/src/routes/[handle]/[repo]/settings/labels/new/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/labels/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/labels/new/+page.svelte @@ -5,6 +5,7 @@ import Input from "$lib/components/ui/Input.svelte"; import Radio from "$lib/components/ui/Radio.svelte"; import Checkbox from "$lib/components/ui/Checkbox.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import DrillDown from "$lib/components/settings/DrillDown.svelte"; import SettingsList from "$lib/components/settings/SettingsList.svelte"; import FormRow from "$lib/components/settings/FormRow.svelte"; @@ -13,8 +14,6 @@ import X from "$icon/x"; let { data } = $props(); - - const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); const COLORS = [ "#ef4444", @@ -35,56 +34,66 @@ let color = $state(COLORS[0]); const valid = $derived(name.trim().length > 0 && (scopeIssues || scopePulls)); - const submit = () => goto(resolve(back as "/")); + const submit = () => { + void Promise.resolve(data.repo).then((repo) => { + void goto(resolve(`/${repo.ownerHandle}/${repo.name}/settings` as "/")); + }); + }; - - - -
- Basic - Key-value -
-
+ + + {@const repo = await data.repo} + {@const back = `/${repo.ownerHandle}/${repo.name}/settings`} + + + +
+ Basic + Key-value +
+
- - - + + + - -
- Issues - Pull requests -
-
+ +
+ Issues + Pull requests +
+
- -
- {#each COLORS as swatch (swatch)} - - {/each} -
-
-
+ +
+ {#each COLORS as swatch (swatch)} + + {/each} +
+
+
- - - - -
+ + + + + + diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte --- a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte @@ -3,37 +3,50 @@ import LastCommitPanel from "$lib/components/repo/LastCommitPanel.svelte"; import Readme from "$lib/components/repo/Readme.svelte"; import TreeHeader from "$lib/components/repo/TreeHeader.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; let { data } = $props(); - - const repo = $derived(data.repo); - - + + + {@const repo = await data.repo} + {@const tree = await data.tree} + + - {#if data.lastCommit} - + {#if tree.lastCommit} + + {/if} + + + + + {#if tree.readme} + + + {/if} - - - - - -{#if data.readme} - -{/if} + diff --git a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte --- a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte @@ -1,49 +1,25 @@ - {data.workflow.name} · Pipeline {data.pipeline.id} · Tangled + {page.params.workflow} · Pipeline {page.params.pipeline} · Tangled - - + {@const repo = await data.repo} + {@const pipeline = await data.pipeline} + {@const workflow = await data.workflow} + {@const spindle = await data.spindle} + - - -
- -
+ diff --git a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts --- a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts @@ -1,26 +1,34 @@ import { error } from "@sveltejs/kit"; +import { stream } from "$lib/api/load"; import { createSpindleClient, getPipeline, toPipelineSummary } from "$lib/api/spindle"; import type { PageLoad } from "./$types"; export const load: PageLoad = async (event) => { const parent = await event.parent(); - const { spindle, repoDid } = parent.repo; + const repo = Promise.resolve(parent.repo); - if (!spindle || !repoDid) error(404, "This repo has no spindle"); + const loaded = repo.then(async (repo) => { + const { spindle, repoDid } = repo; + if (!spindle || !repoDid) error(404, "This repo has no spindle"); - const ctx = createSpindleClient(spindle, event.fetch); + const ctx = createSpindleClient(spindle, event.fetch); - const result = await getPipeline(ctx, { pipeline: event.params.pipeline }).catch(() => { - // unknown id or dead spindle is a 404 for this page - error(404, `Could not load pipeline ${event.params.pipeline} from ${spindle}`); + const result = await getPipeline(ctx, { pipeline: event.params.pipeline }).catch(() => { + error(404, `Could not load pipeline ${event.params.pipeline} from ${spindle}`); + }); + + if (result.repo !== repoDid) error(404, "Pipeline belongs to another repo"); + + const pipeline = toPipelineSummary(result, repoDid); + const workflow = pipeline.workflows.find((each) => each.name === event.params.workflow); + if (!workflow) error(404, `No workflow named ${event.params.workflow} in this run`); + + return { spindle, pipeline, workflow }; }); - // spindle returning another repo is invalid - if (result.repo !== repoDid) error(404, "Pipeline belongs to another repo"); - - const pipeline = toPipelineSummary(result, repoDid); - const workflow = pipeline.workflows.find((each) => each.name === event.params.workflow); - if (!workflow) error(404, `No workflow named ${event.params.workflow} in this run`); - - return { spindle, pipeline, workflow }; + return { + spindle: stream(loaded.then((l) => l.spindle)), + pipeline: stream(loaded.then((l) => l.pipeline)), + workflow: stream(loaded.then((l) => l.workflow)) + }; }; diff --git a/web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte b/web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte --- a/web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte @@ -3,6 +3,7 @@ import { resolve } from "$app/paths"; import Button from "$lib/components/ui/Button.svelte"; import Input from "$lib/components/ui/Input.svelte"; + import Pending from "$lib/components/ui/Pending.svelte"; import DrillDown from "$lib/components/settings/DrillDown.svelte"; import SettingsList from "$lib/components/settings/SettingsList.svelte"; import FormRow from "$lib/components/settings/FormRow.svelte"; @@ -12,33 +13,40 @@ let { data } = $props(); - const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings/pipelines`); - let name = $state(""); let value = $state(""); const valid = $derived(name.trim().length > 0 && value.length > 0); // mocked — secrets are not wired up yet - const submit = () => goto(resolve(back as "/")); + const submit = () => { + void Promise.resolve(data.repo).then((repo) => { + void goto(resolve(`/${repo.ownerHandle}/${repo.name}/settings/pipelines` as "/")); + }); + }; - - - - - - - - - + + + {@const repo = await data.repo} + {@const back = `/${repo.ownerHandle}/${repo.name}/settings/pipelines`} + + + + + + + + + - - - - - + + + + + +