diff --git a/web/src/app.css b/web/src/app.css index 94baea7e1..3549e93d3 100644 --- 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/api/blob.ts b/web/src/lib/api/blob.ts index c880167e7..6b95e9f2b 100644 --- a/web/src/lib/api/blob.ts +++ b/web/src/lib/api/blob.ts @@ -86,7 +86,8 @@ export const loadRepoBlob = async ( 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 @@ export const loadRepoBlob = async ( 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 index 38d9024b9..03554e55e 100644 --- a/web/src/lib/api/commitStatuses.ts +++ b/web/src/lib/api/commitStatuses.ts @@ -3,7 +3,8 @@ import type { PipelineSummary } from "$lib/components/repo/types"; 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 @@ export const commitStatuses = ( ): 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 index 7e9bd8503..eb93f2a07 100644 --- a/web/src/lib/api/load.ts +++ b/web/src/lib/api/load.ts @@ -41,6 +41,12 @@ export const parallel = async >>( 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 index 349cb13c2..592420614 100644 --- 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 @@ export interface ComposeData { // 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 index 1132a1ccb..561457bb7 100644 --- a/web/src/lib/api/repoIndex.ts +++ b/web/src/lib/api/repoIndex.ts @@ -9,7 +9,7 @@ import { 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 { renderDocument } from "$lib/markup"; 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 @@ const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ 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 @@ const withAuthorHandles = async ( ); }; -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 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 languages = toLanguageSlices(results.languages.value?.languages ?? []); + 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 readme = readmeOf(results.tree.value); - const readmeHtml = await renderReadme(readme, parent, event, ref); + 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 contentAttempts = [results.tree, results.log, results.branches]; - const knot = classifyRepoAvailability(contentAttempts); - const availability: RepoAvailability = - knot === "ok" && files.length === 0 && branches.length === 0 ? "empty" : knot; + 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 ?? []); + }); - // 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`); - } + const readmeHtml = Promise.all([repoP, content]).then(([repo, resolved]) => + renderReadme(resolved.readme, repo, parent.publicConfig, event, resolved.ref) + ); 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); - - // 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 repoP = Promise.resolve(parent.repo); + + 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}`); + } + + return { + files, + readme: readmeOf(result), + lastCommit: result.lastCommit ? toTreeCommitSummary(result.lastCommit) : null + }; + }); - const readme = readmeOf(tree); - const readmeHtml = await renderReadme(readme, parent, event, ref, path); + 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/lib/components/profile/ProfileChrome.svelte b/web/src/lib/components/profile/ProfileChrome.svelte new file mode 100644 index 000000000..d080772f7 --- /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 index 000000000..62a1a844b --- /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 index 1b9b7b617..931e8ed74 100644 --- 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 index a742e5e3a..ab4599656 100644 --- 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 index 4ebc39fcb..fe7ced25f 100644 --- 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/profile/tabs/RepoListTab.svelte b/web/src/lib/components/profile/tabs/RepoListTab.svelte index 6d03cc870..eae09118f 100644 --- a/web/src/lib/components/profile/tabs/RepoListTab.svelte +++ b/web/src/lib/components/profile/tabs/RepoListTab.svelte @@ -50,7 +50,9 @@
{#snippet header()}
diff --git a/web/src/lib/components/profile/tabs/StarredTab.svelte b/web/src/lib/components/profile/tabs/StarredTab.svelte index 47dcb4de4..d034d2c73 100644 --- a/web/src/lib/components/profile/tabs/StarredTab.svelte +++ b/web/src/lib/components/profile/tabs/StarredTab.svelte @@ -22,14 +22,18 @@ const auth = getAuth(); - const pager = createOffsetPager(untrack(() => initial), PROFILE_PAGE_LIMIT, (offset) => { - const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); - return fetchStarredPage(ctx, { - did, - viewerDid: auth.currentDid ?? undefined, - offset - }).then((p) => p.items); - }); + const pager = createOffsetPager( + untrack(() => initial), + PROFILE_PAGE_LIMIT, + (offset) => { + const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); + return fetchStarredPage(ctx, { + did, + viewerDid: auth.currentDid ?? undefined, + offset + }).then((p) => p.items); + } + ); const stars = $derived(pager.items); const pages = $derived(pageCount(total, PROFILE_PAGE_LIMIT)); diff --git a/web/src/lib/components/repo/BlobHeader.stories.svelte b/web/src/lib/components/repo/BlobHeader.stories.svelte index a068bee1f..0b7a18c58 100644 --- 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 index 6f650ecc5..48bfeebb7 100644 --- 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 index 04b9c9503..39a55b746 100644 --- 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 index 8d0513f90..8a3cca7b1 100644 --- 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 index 11d333aa9..4b06f0e6d 100644 --- 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 index 1cf4bc203..f84e17f14 100644 --- 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 index 5e737a60b..607f681d9 100644 --- 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 index 80eb05e09..69e8f1ad1 100644 --- 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 index e60b0f882..0d06460f9 100644 --- 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 @@