diff --git a/src/App.tsx b/src/App.tsx index eeae0c6..6804abb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ -import { Route, Routes } from 'react-router-dom' +import { Navigate, Route, Routes, useParams } from 'react-router-dom' import { PageLayout } from './components/layout/PageLayout' import { ErrorPage } from './components/shared/ErrorPage' import { HomePage } from './pages/HomePage' +import { IssuePage } from './pages/IssuePage' import { ProfilePage } from './pages/ProfilePage' +import { PullPage } from './pages/PullPage' import { RepoPage } from './pages/RepoPage' import { StringPage } from './pages/StringPage' @@ -11,6 +13,10 @@ function App() { } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -25,4 +31,18 @@ function App() { ) } +type RepoSectionRedirectProps = { + section: 'issues' | 'pulls' +} + +function RepoSectionRedirect({ section }: RepoSectionRedirectProps) { + const { handle, repo } = useParams() + + if (handle === undefined || repo === undefined) { + return + } + + return +} + export default App diff --git a/src/components/profile/ProfileByline.tsx b/src/components/profile/ProfileByline.tsx index ce8cf63..0b38328 100644 --- a/src/components/profile/ProfileByline.tsx +++ b/src/components/profile/ProfileByline.tsx @@ -8,10 +8,24 @@ type ProfileBylineProps = { miniDoc: MiniDoc profile: Profile bskyProfile?: AppBskyActorProfile.Main | null + breadcrumbs?: readonly Breadcrumb[] + label?: string +} + +type Breadcrumb = { label: string + to?: string } -export function ProfileByline({ miniDoc, profile, bskyProfile, label }: ProfileBylineProps) { +export function ProfileByline({ + miniDoc, + profile, + bskyProfile, + breadcrumbs, + label, +}: ProfileBylineProps) { + const items = breadcrumbs ?? (label === undefined ? [] : [{ label }]) + return ( ) diff --git a/src/components/repo/RepoIssues.tsx b/src/components/repo/RepoIssues.tsx index 269883e..6546eda 100644 --- a/src/components/repo/RepoIssues.tsx +++ b/src/components/repo/RepoIssues.tsx @@ -1,14 +1,22 @@ -import type { Did } from '@atcute/lexicons' +import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons' import { IconCircleDot, IconMessageCircle } from '@tabler/icons-react' +import { Link } from 'react-router-dom' +import { useRecordAuthors } from '../../hooks/useRecordAuthors' import { useRepoIssues } from '../../hooks/useRepoIssues' +import { getRecordRkey, type Issue } from '../../lib/tangled/repo' import { SurfaceCard } from '../shared/SurfaceCard' +const EMPTY_ISSUES: readonly Issue[] = [] + type RepoIssuesProps = { + repoOwnerHandle: Handle repoDid: Did + repoKey: string } -export function RepoIssues({ repoDid }: RepoIssuesProps) { +export function RepoIssues({ repoOwnerHandle, repoDid, repoKey }: RepoIssuesProps) { const { issues, error } = useRepoIssues(repoDid) + const { authors, error: authorsError } = useRecordAuthors(issues?.items ?? EMPTY_ISSUES) if (error) { return

Could not load issues: {error.message}

@@ -22,35 +30,72 @@ export function RepoIssues({ repoDid }: RepoIssuesProps) { return

No issues found.

} + const authorResolutionMessage = + authorsError === null ? null : 'Some issues may not be available until their authors resolve.' + return ( -
    - {issues.items.map((issue) => { - const isOpen = issue.state === 'open' - - return ( -
  • - -
    - + {authorResolutionMessage &&

    {authorResolutionMessage}

    } +
      + {issues.items.map((issue) => { + const isOpen = issue.state === 'open' + const authorDid = parseResourceUri(issue.uri).repo + const issueOwner = authors?.get(authorDid) + const issueUrl = + issueOwner === undefined + ? undefined + : `/${repoOwnerHandle}/${repoKey}/issues/${issueOwner}/${getRecordRkey(issue.uri)}` + + return ( +
    • + {issueUrl === undefined ? ( + + ) : ( + -
    - -

    {issue.value.title}

    -
    -
  • - ) - })} -
+ + + )} + + ) + })} + + + ) +} + +type IssueCardProps = { + issue: Issue + isOpen: boolean + linked?: boolean +} + +function IssueCard({ issue, isOpen, linked = false }: IssueCardProps) { + return ( + +
+ + + + + +
+ +

{issue.value.title}

+
) } diff --git a/src/components/repo/RepoPulls.tsx b/src/components/repo/RepoPulls.tsx index ff8accf..f84cadc 100644 --- a/src/components/repo/RepoPulls.tsx +++ b/src/components/repo/RepoPulls.tsx @@ -1,14 +1,22 @@ -import type { Did } from '@atcute/lexicons' +import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons' import { IconGitPullRequest, IconMessageCircle } from '@tabler/icons-react' +import { Link } from 'react-router-dom' +import { useRecordAuthors } from '../../hooks/useRecordAuthors' import { useRepoPulls } from '../../hooks/useRepoPulls' +import { getRecordRkey, type Pull } from '../../lib/tangled/repo' import { SurfaceCard } from '../shared/SurfaceCard' +const EMPTY_PULLS: readonly Pull[] = [] + type RepoPullsProps = { + repoOwnerHandle: Handle repoDid: Did + repoKey: string } -export function RepoPulls({ repoDid }: RepoPullsProps) { +export function RepoPulls({ repoOwnerHandle, repoDid, repoKey }: RepoPullsProps) { const { pulls, error } = useRepoPulls(repoDid) + const { authors, error: authorsError } = useRecordAuthors(pulls?.items ?? EMPTY_PULLS) if (error) { return

Could not load pulls: {error.message}

@@ -22,36 +30,75 @@ export function RepoPulls({ repoDid }: RepoPullsProps) { return

No pulls found.

} + const authorResolutionMessage = + authorsError === null + ? null + : 'Some pull requests may not be available until their authors resolve.' + return ( -
    - {pulls.items.map((pull) => { - const stateClassName = - pull.state === 'open' - ? 'text-ctp-teal' - : pull.state === 'merged' - ? 'text-ctp-mauve' - : 'text-ctp-overlay-1' - - return ( -
  • - -
    - - - - - -
    - -

    {pull.value.title}

    -
    -
  • - ) - })} -
+ <> + {authorResolutionMessage &&

{authorResolutionMessage}

} +
    + {pulls.items.map((pull) => { + const stateClassName = + pull.state === 'open' + ? 'text-ctp-teal' + : pull.state === 'merged' + ? 'text-ctp-mauve' + : 'text-ctp-overlay-1' + const authorDid = parseResourceUri(pull.uri).repo + const pullOwner = authors?.get(authorDid) + const pullUrl = + pullOwner === undefined + ? undefined + : `/${repoOwnerHandle}/${repoKey}/pulls/${pullOwner}/${getRecordRkey(pull.uri)}` + + return ( +
  • + {pullUrl === undefined ? ( + + ) : ( + + + + )} +
  • + ) + })} +
+ + ) +} + +type PullCardProps = { + linked?: boolean + pull: Pull + stateClassName: string +} + +function PullCard({ linked = false, pull, stateClassName }: PullCardProps) { + return ( + +
+ + + + + +
+ +

{pull.value.title}

+
) } diff --git a/src/components/repo/RepoRecordView.tsx b/src/components/repo/RepoRecordView.tsx new file mode 100644 index 0000000..cd0b038 --- /dev/null +++ b/src/components/repo/RepoRecordView.tsx @@ -0,0 +1,90 @@ +import { Link } from 'react-router-dom' +import type { ResolvedActor } from '../../lib/actor' +import { ProfileAvatar } from '../profile/ProfileAvatar' +import { MarkdownContent } from '../shared/MarkdownContent' +import { SurfaceCard } from '../shared/SurfaceCard' + +type RepoRecordViewProps = { + author: ResolvedActor + body?: string + createdAt: string + mentions?: string[] + references?: string[] + title: string +} + +export function RepoRecordView({ + author, + body, + createdAt, + mentions, + references, + title, +}: RepoRecordViewProps) { + const { description } = author.profile.value + + return ( + +
+
+ + + +
+ + {author.miniDoc.handle} + + {description && ( +

{description}

+ )} +
+
+ +

{title}

+ +
+
+
Opened
+
+ +
+
+ {mentions && mentions.length > 0 && ( + + )} + {references && references.length > 0 && ( + + )} +
+
+ + {body && ( + + {body} + + )} +
+ ) +} + +function RecordCount({ label, count }: { label: string; count: number }) { + return ( +
+
{label}
+
{count}
+
+ ) +} diff --git a/src/hooks/useRecordAuthors.ts b/src/hooks/useRecordAuthors.ts new file mode 100644 index 0000000..7732c94 --- /dev/null +++ b/src/hooks/useRecordAuthors.ts @@ -0,0 +1,57 @@ +import { parseResourceUri, type Handle, type ResourceUri } from '@atcute/lexicons' +import { useEffect, useMemo, useState } from 'react' +import { resolveMiniDoc } from '../lib/actor' + +type RecordWithUri = { + uri: ResourceUri +} + +type AuthorsState = { + authors: Map + error: Error | null + key: string +} + +export function useRecordAuthors(records: readonly RecordWithUri[]) { + const authorDids = useMemo( + () => [...new Set(records.map(({ uri }) => parseResourceUri(uri).repo))], + [records], + ) + const authorsKey = [...authorDids].sort().join(',') + const [state, setState] = useState(null) + + useEffect(() => { + let isCancelled = false + + void Promise.allSettled( + authorDids.map(async (did) => [did, (await resolveMiniDoc(did)).handle] as const), + ).then((results) => { + if (isCancelled) return + + const authors = new Map() + const failedResolutions = results.filter((result) => result.status === 'rejected') + for (const result of results) { + if (result.status === 'fulfilled') authors.set(...result.value) + } + + setState({ + authors, + error: + failedResolutions.length > 0 + ? new Error('Some record authors could not be resolved') + : null, + key: authorsKey, + }) + }) + + return () => { + isCancelled = true + } + }, [authorDids, authorsKey]) + + const isCurrentList = state?.key === authorsKey + return { + authors: isCurrentList ? (state.authors ?? null) : null, + error: isCurrentList ? (state.error ?? null) : null, + } +} diff --git a/src/hooks/useRepoRecordPage.ts b/src/hooks/useRepoRecordPage.ts new file mode 100644 index 0000000..834e6c9 --- /dev/null +++ b/src/hooks/useRepoRecordPage.ts @@ -0,0 +1,102 @@ +import type { Handle } from '@atcute/lexicons' +import { useEffect, useState } from 'react' + +type LoadRecordPage = ( + repoOwnerHandle: Handle, + repoKey: string, + recordOwnerHandle: Handle, + recordKey: string, +) => Promise + +type UseRepoRecordPageOptions = { + loadPage: LoadRecordPage + recordKey: string | undefined + recordOwnerHandle: Handle | null + repoKey: string | undefined + repoOwnerHandle: Handle | null +} + +type PageState = { + data?: TPageData + error?: Error + key: string +} + +export function useRepoRecordPage({ + loadPage, + recordKey, + recordOwnerHandle, + repoKey, + repoOwnerHandle, +}: UseRepoRecordPageOptions) { + const route = getRecordRoute(repoOwnerHandle, repoKey, recordOwnerHandle, recordKey) + const pageKey = route?.key ?? null + const [state, setState] = useState | null>(null) + + useEffect(() => { + const currentRoute = getRecordRoute(repoOwnerHandle, repoKey, recordOwnerHandle, recordKey) + if (currentRoute === null) return + + let isCancelled = false + + void loadPage( + currentRoute.repoOwnerHandle, + currentRoute.repoKey, + currentRoute.recordOwnerHandle, + currentRoute.recordKey, + ) + .then((data) => { + if (!isCancelled) setState({ key: currentRoute.key, data }) + }) + .catch((caught) => { + if (!isCancelled) { + setState({ + key: currentRoute.key, + error: caught instanceof Error ? caught : new Error('Unable to load record'), + }) + } + }) + + return () => { + isCancelled = true + } + }, [loadPage, recordKey, recordOwnerHandle, repoKey, repoOwnerHandle]) + + const isCurrentPage = state?.key === pageKey + return { + error: isCurrentPage ? (state.error ?? null) : null, + pageData: isCurrentPage ? (state.data ?? null) : null, + } +} + +type RecordRoute = { + key: string + recordKey: string + recordOwnerHandle: Handle + repoKey: string + repoOwnerHandle: Handle +} + +function getRecordRoute( + repoOwnerHandle: Handle | null, + repoKey: string | undefined, + recordOwnerHandle: Handle | null, + recordKey: string | undefined, +): RecordRoute | null { + if ( + repoOwnerHandle === null || + repoKey === undefined || + recordOwnerHandle === null || + recordKey === undefined + ) { + return null + } + + return { + key: `${repoOwnerHandle}/${repoKey}/${recordOwnerHandle}/${recordKey}`, + recordKey, + recordOwnerHandle, + repoKey, + repoOwnerHandle, + } +} diff --git a/src/lib/issuePage.ts b/src/lib/issuePage.ts new file mode 100644 index 0000000..15c818b --- /dev/null +++ b/src/lib/issuePage.ts @@ -0,0 +1,27 @@ +import type { Handle } from '@atcute/lexicons' +import { loadRepoRecordPage, type RepoRecordPageData } from './repoRecordPage' +import { getIssue, type IssueRecord } from './tangled/repo' + +export type IssuePageData = RepoRecordPageData + +export async function loadIssuePage( + repoOwnerHandle: Handle, + repoKey: string, + issueOwnerHandle: Handle, + issueKey: string, +): Promise { + const page = await loadRepoRecordPage({ + collection: 'sh.tangled.repo.issue', + getRecord: getIssue, + recordKey: issueKey, + recordOwnerHandle: issueOwnerHandle, + repoKey, + repoOwnerHandle, + }) + + if (page.repo.value.repoDid !== page.record.value.repo) { + throw new Error('Issue does not belong to this repository') + } + + return page +} diff --git a/src/lib/pullPage.ts b/src/lib/pullPage.ts new file mode 100644 index 0000000..a529adb --- /dev/null +++ b/src/lib/pullPage.ts @@ -0,0 +1,27 @@ +import type { Handle } from '@atcute/lexicons' +import { loadRepoRecordPage, type RepoRecordPageData } from './repoRecordPage' +import { getPull, type PullRecord } from './tangled/repo' + +export type PullPageData = RepoRecordPageData + +export async function loadPullPage( + repoOwnerHandle: Handle, + repoKey: string, + pullOwnerHandle: Handle, + pullKey: string, +): Promise { + const page = await loadRepoRecordPage({ + collection: 'sh.tangled.repo.pull', + getRecord: getPull, + recordKey: pullKey, + recordOwnerHandle: pullOwnerHandle, + repoKey, + repoOwnerHandle, + }) + + if (page.repo.value.repoDid !== page.record.value.target.repo) { + throw new Error('Pull request does not belong to this repository') + } + + return page +} diff --git a/src/lib/repoRecordPage.ts b/src/lib/repoRecordPage.ts new file mode 100644 index 0000000..8b54a96 --- /dev/null +++ b/src/lib/repoRecordPage.ts @@ -0,0 +1,44 @@ +import type { Handle, ResourceUri } from '@atcute/lexicons' +import { resolveActor, resolveMiniDoc, type ResolvedActor } from './actor' +import { getRepo, type Repo, type TangledRecord } from './tangled/repo' + +type RecordLoader = (uri: ResourceUri) => Promise + +export type RepoRecordPageData = { + record: TRecord + recordAuthor: ResolvedActor + repo: Repo + repositoryOwner: ResolvedActor +} + +type LoadRepoRecordPageOptions> = { + collection: string + getRecord: RecordLoader + recordKey: string + recordOwnerHandle: Handle + repoKey: string + repoOwnerHandle: Handle +} + +export async function loadRepoRecordPage>({ + collection, + getRecord, + recordKey, + recordOwnerHandle, + repoKey, + repoOwnerHandle, +}: LoadRepoRecordPageOptions): Promise> { + const repoIdentity = await resolveMiniDoc(repoOwnerHandle) + const recordIdentity = + repoOwnerHandle === recordOwnerHandle ? repoIdentity : await resolveMiniDoc(recordOwnerHandle) + const repoUri = `at://${repoIdentity.did}/sh.tangled.repo/${repoKey}` as ResourceUri + const recordUri = `at://${recordIdentity.did}/${collection}/${recordKey}` as ResourceUri + const [owner, author, repo, record] = await Promise.all([ + resolveActor(repoIdentity.did), + resolveActor(recordIdentity.did), + getRepo(repoUri), + getRecord(recordUri), + ]) + + return { record, recordAuthor: author, repo, repositoryOwner: owner } +} diff --git a/src/lib/tangled/repo/getPull.ts b/src/lib/tangled/repo/getPull.ts new file mode 100644 index 0000000..9b1501c --- /dev/null +++ b/src/lib/tangled/repo/getPull.ts @@ -0,0 +1,15 @@ +import { ok } from '@atcute/client' +import type { ResourceUri } from '@atcute/lexicons' +import { rpc } from '../client' +import type { PullRecord } from './types' +import { validatePullRecord } from './validators' + +export async function getPull(pull: ResourceUri): Promise { + const response = await ok( + rpc.get('sh.tangled.repo.getPull', { + params: { pull }, + }), + ) + + return validatePullRecord(response) +} diff --git a/src/lib/tangled/repo/index.ts b/src/lib/tangled/repo/index.ts index 0181fbd..4fa1ff9 100644 --- a/src/lib/tangled/repo/index.ts +++ b/src/lib/tangled/repo/index.ts @@ -5,6 +5,7 @@ export { getRecentCommits } from './getRecentCommits' export type { RecentCommitsOptions, RepoCommit } from './getRecentCommits' export { getRepo } from './getRepo' export { getRepoByRepoDid } from './getRepoByRepoDid' +export { getPull } from './getPull' export { getRepoLog } from './getRepoLog' export type { RepoLogOptions } from './getRepoLog' export { getRepoTree } from './getRepoTree' @@ -13,6 +14,7 @@ export type { ListIssuesOptions } from './listIssues' export { listPulls } from './listPulls' export type { ListPullsOptions } from './listPulls' export { listRepos } from './listRepos' +export { getRecordRkey } from './recordIdentity' export { getRepoName, getRepoRkey } from './repoIdentity' export type { Issue, diff --git a/src/lib/tangled/repo/recordIdentity.ts b/src/lib/tangled/repo/recordIdentity.ts new file mode 100644 index 0000000..0cc31e8 --- /dev/null +++ b/src/lib/tangled/repo/recordIdentity.ts @@ -0,0 +1,11 @@ +import type { ResourceUri } from '@atcute/lexicons' +import { parseResourceUri } from '@atcute/lexicons' + +export function getRecordRkey(uri: ResourceUri): string { + const { rkey } = parseResourceUri(uri) + if (rkey === undefined) { + throw new Error(`Record URI has no record key: ${uri}`) + } + + return rkey +} diff --git a/src/lib/tangled/repo/repoIdentity.ts b/src/lib/tangled/repo/repoIdentity.ts index 06fccd3..a7e46bc 100644 --- a/src/lib/tangled/repo/repoIdentity.ts +++ b/src/lib/tangled/repo/repoIdentity.ts @@ -1,13 +1,8 @@ -import { parseResourceUri } from '@atcute/lexicons' import type { Repo } from './types' +import { getRecordRkey } from './recordIdentity' export function getRepoRkey(repo: Repo): string { - const { rkey } = parseResourceUri(repo.uri) - if (rkey === undefined) { - throw new Error(`Repository URI has no record key: ${repo.uri}`) - } - - return rkey + return getRecordRkey(repo.uri) } export function getRepoName(repo: Repo): string { diff --git a/src/pages/IssuePage.tsx b/src/pages/IssuePage.tsx new file mode 100644 index 0000000..327cd8d --- /dev/null +++ b/src/pages/IssuePage.tsx @@ -0,0 +1,75 @@ +import { useParams } from 'react-router-dom' +import { PageContainer } from '../components/layout/PageContainer' +import { ProfileByline } from '../components/profile/ProfileByline' +import { ErrorPage } from '../components/shared/ErrorPage' +import { RepoRecordView } from '../components/repo/RepoRecordView' +import { RepoPageSkeleton } from '../components/shared/PageSkeletons' +import { useRepoRecordPage } from '../hooks/useRepoRecordPage' +import { loadIssuePage } from '../lib/issuePage' +import { parseHandle } from '../lib/routes' +import { getRepoName, getRepoRkey } from '../lib/tangled/repo' + +export function IssuePage() { + const { + repoOwner: routeRepoOwner, + repo: repoKey, + issueOwner: routeIssueOwner, + issue: issueKey, + } = useParams() + const repoOwnerHandle = parseHandle(routeRepoOwner) + const issueOwnerHandle = parseHandle(routeIssueOwner) + const { pageData, error } = useRepoRecordPage({ + loadPage: loadIssuePage, + recordKey: issueKey, + recordOwnerHandle: issueOwnerHandle, + repoKey, + repoOwnerHandle, + }) + + if ( + repoOwnerHandle === null || + issueOwnerHandle === null || + repoKey === undefined || + issueKey === undefined + ) { + return + } + + if (error) { + return ( + + ) + } + + if (pageData === null) { + return + } + + const { record: issue, recordAuthor, repo, repositoryOwner } = pageData + const repositoryUrl = `/${repositoryOwner.miniDoc.handle}/${getRepoRkey(repo)}` + const repoUrl = `${repositoryUrl}?view=issues` + + return ( +
+ + +
+ +
+
+
+ ) +} diff --git a/src/pages/PullPage.tsx b/src/pages/PullPage.tsx new file mode 100644 index 0000000..da0dc2d --- /dev/null +++ b/src/pages/PullPage.tsx @@ -0,0 +1,76 @@ +import { useParams } from 'react-router-dom' +import { PageContainer } from '../components/layout/PageContainer' +import { ProfileByline } from '../components/profile/ProfileByline' +import { RepoRecordView } from '../components/repo/RepoRecordView' +import { ErrorPage } from '../components/shared/ErrorPage' +import { RepoPageSkeleton } from '../components/shared/PageSkeletons' +import { useRepoRecordPage } from '../hooks/useRepoRecordPage' +import { loadPullPage } from '../lib/pullPage' +import { parseHandle } from '../lib/routes' +import { getRepoName, getRepoRkey } from '../lib/tangled/repo' + +export function PullPage() { + const { + repoOwner: routeRepoOwner, + repo: repoKey, + pullOwner: routePullOwner, + pull: pullKey, + } = useParams() + const repoOwnerHandle = parseHandle(routeRepoOwner) + const pullOwnerHandle = parseHandle(routePullOwner) + const { pageData, error } = useRepoRecordPage({ + loadPage: loadPullPage, + recordKey: pullKey, + recordOwnerHandle: pullOwnerHandle, + repoKey, + repoOwnerHandle, + }) + + if ( + repoOwnerHandle === null || + pullOwnerHandle === null || + repoKey === undefined || + pullKey === undefined + ) { + return ( + + ) + } + + if (error) { + return ( + + ) + } + + if (pageData === null) { + return + } + + const { record: pull, recordAuthor, repo, repositoryOwner } = pageData + const repositoryUrl = `/${repositoryOwner.miniDoc.handle}/${getRepoRkey(repo)}` + + return ( +
+ + +
+ +
+
+
+ ) +} diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index c4491d6..7ce03e1 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -92,10 +92,18 @@ export function RepoPage() { )} {activeSection === 'issues' && repo.value.repoDid !== undefined && ( - + )} {activeSection === 'pulls' && repo.value.repoDid !== undefined && ( - + )} {activeSection === 'pipelines' && }