diff --git a/public/oauth-client-metadata.json b/public/oauth-client-metadata.json index fe4b332..122b335 100644 --- a/public/oauth-client-metadata.json +++ b/public/oauth-client-metadata.json @@ -3,7 +3,7 @@ "client_name": "untangled", "client_uri": "https://untangled.wisp.place", "redirect_uris": ["https://untangled.wisp.place/oauth/callback"], - "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* blob:*/*", + "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* blob:*/*", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/src/index.css b/src/index.css index 981e3e6..6e4a584 100644 --- a/src/index.css +++ b/src/index.css @@ -2321,7 +2321,7 @@ details[open] > .untangled-language-bar { .untangled-repo-actions { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; width: fit-content; } @@ -2366,9 +2366,9 @@ details[open] > .untangled-language-bar { } .untangled-repo-action-main { - gap: 0.5rem; - min-width: 4.75rem; - padding: 0 0.75rem; + gap: 0.35rem; + min-width: 3.5rem; + padding: 0 0.5rem; flex: 1 1 auto; justify-content: center; position: relative; @@ -2396,8 +2396,8 @@ details[open] > .untangled-language-bar { border-left: 1px solid rgb(209 213 219); background: rgb(249 250 251); justify-content: center; - min-width: 2.5rem; - padding: 0 0.75rem; + min-width: 2rem; + padding: 0 0.5rem; position: relative; text-decoration: none; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 91577a6..ef7d432 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -32,6 +32,9 @@ export { getRepoTags, getRepoTree, listRepoRecords, + compareFork, + createHiddenRef, + getRepoForkCount, } from './api/repos'; export { createRepoStar, diff --git a/src/lib/api/pulls.ts b/src/lib/api/pulls.ts index 4c76a87..c19802d 100644 --- a/src/lib/api/pulls.ts +++ b/src/lib/api/pulls.ts @@ -1,5 +1,5 @@ import { ok } from '@atcute/client'; -import type { ResourceUri } from '@atcute/lexicons/syntax'; +import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; import { ShTangledRepoMerge, @@ -83,6 +83,7 @@ export interface CreatePullInput { targetBranch: string; sourceBranch: string; patch: string; + sourceRepoDid?: Did; } export interface MergePullInput { @@ -362,6 +363,7 @@ export const createPull = async ( }, source: { branch: input.sourceBranch, + repo: input.sourceRepoDid, }, rounds: [ { diff --git a/src/lib/api/repos.ts b/src/lib/api/repos.ts index 42a0270..f378862 100644 --- a/src/lib/api/repos.ts +++ b/src/lib/api/repos.ts @@ -11,7 +11,9 @@ import { listAllAppviewRecords, type AppviewRecordView, } from './appview'; -import { getRpc, normalizeServiceUrl } from './client'; +import { getBacklinksPage } from './backlinks'; +import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; +import { getRpc, normalizeServiceUrl, createServiceAuthRpc } from './client'; import { REPO_COLLECTION } from './constants'; import { resolveActor } from './identity'; import { @@ -608,3 +610,55 @@ export const buildBlobDataUrl = (blob: BlobResponse): string | null => { return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; }; + +export const createHiddenRef = async ( + agent: OAuthUserAgent, + forkKnot: string, + input: { + forkRef: string; + remoteRef: string; + repo: ResourceUri; + }, +): Promise => { + const rpc = await createServiceAuthRpc(agent, forkKnot, 'sh.tangled.repo.hiddenRef'); + await ok( + rpc.post('sh.tangled.repo.hiddenRef', { + input: { + forkRef: input.forkRef, + remoteRef: input.remoteRef, + repo: input.repo, + }, + }), + ); +}; + +export const compareFork = async ( + agent: OAuthUserAgent, + forkRepo: RepoContext, + targetBranch: string, + sourceBranch: string, +): Promise => { + await createHiddenRef(agent, forkRepo.knot, { + forkRef: sourceBranch, + remoteRef: targetBranch, + repo: forkRepo.record.uri, + }); + + const hiddenRef = `hidden/${sourceBranch}/${targetBranch}`; + return compareBranches(forkRepo, hiddenRef, sourceBranch); +}; + +export const getRepoForkCount = async (repo: RepoContext): Promise => { + try { + const source = 'sh.tangled.repo:source'; + const [didPage, uriPage] = await Promise.all([ + getBacklinksPage(repo.repoDid, source, 1), + getBacklinksPage(repo.record.uri, source, 1), + ]); + const didCount = didPage.total ?? didPage.records.length; + const uriCount = uriPage.total ?? uriPage.records.length; + return didCount + uriCount; + } catch { + return 0; + } +}; diff --git a/src/lib/repo-utils.ts b/src/lib/repo-utils.ts index 90dfeda..03d13e8 100644 --- a/src/lib/repo-utils.ts +++ b/src/lib/repo-utils.ts @@ -23,6 +23,7 @@ export interface RecentRepo { slug: string; title: string; description?: string; + isFork?: boolean; language?: string; stars?: number; openIssues?: number; @@ -226,6 +227,7 @@ export const saveRecentRepo = ( slug: repo.slug, title: `${repo.owner.handle}/${repo.slug}`, description: repo.record.value.description?.trim() || undefined, + isFork: Boolean(repo.record.value.source), language: existing?.language, stars: stats?.stars ?? existing?.stars, openIssues: stats?.openIssues ?? existing?.openIssues, diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 2a6f4ac..da364f6 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -513,6 +513,7 @@ export const HomePage: Component = () => { owner={repo.owner} name={repo.slug} description={repo.description} + isFork={repo.isFork} language={repo.language} stars={repo.stars} openIssues={repo.openIssues} diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index 82b5fb7..cef376e 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -18,7 +18,7 @@ import { Check, Pencil, } from 'lucide-solid'; -import { A, useParams, useSearchParams } from '@solidjs/router'; +import { A, useParams, useSearchParams, useNavigate } from '@solidjs/router'; import { createQuery, useQueryClient } from '@tanstack/solid-query'; import { For, Show, Switch, Match, createMemo, createSignal, createEffect, type Component } from 'solid-js'; import type { Did } from '@atcute/lexicons/syntax'; @@ -864,20 +864,53 @@ export const ProfilePage: Component = () => { const queryClient = useQueryClient(); const auth = useAuth(); + const navigate = useNavigate(); + const [repoSearch, setRepoSearch] = createSignal(''); const [actionLoading, setActionLoading] = createSignal(false); + const [isRedirecting, setIsRedirecting] = createSignal(false); const actorParam = () => params.actor as string; - const activeTab = () => { - const tab = searchParams.tab; - return (Array.isArray(tab) ? tab[0] : tab) || 'overview'; - }; + + createEffect(() => { + actorParam(); + setIsRedirecting(false); + }); + + const isDid = () => actorParam().startsWith('did:'); const actorQuery = createQuery(() => ({ queryKey: ['profile-actor', actorParam()], queryFn: () => resolveActor(actorParam()), })); + const repoDidQuery = createQuery(() => ({ + queryKey: ['repo-by-did', actorParam()], + enabled: isDid(), + queryFn: () => getRepoByDid(actorParam() as Did), + retry: false, + })); + + createEffect(() => { + const repo = repoDidQuery.data; + if (repo) { + setIsRedirecting(true); + navigate(`/${repo.owner.handle}/${repo.slug}`, { replace: true }); + } + }); + + createEffect(() => { + const err = repoDidQuery.error; + if (err) { + console.error('Failed to resolve DID as repo:', err); + } + }); + + const activeTab = () => { + const tab = searchParams.tab; + return (Array.isArray(tab) ? tab[0] : tab) || 'overview'; + }; + const profileQuery = createQuery(() => ({ queryKey: ['profile-record', actorQuery.data?.did], enabled: !!actorQuery.data, @@ -1094,13 +1127,20 @@ export const ProfilePage: Component = () => { return ( - + -
- -
+ + + + } + > + +
@@ -1210,6 +1250,7 @@ export const ProfilePage: Component = () => { owner={resolvedActor().handle} name={repo.value.name || repo.rkey} description={repo.value.description} + isFork={Boolean(repo.value.source)} showOwner={false} /> )} @@ -1277,6 +1318,7 @@ export const ProfilePage: Component = () => { owner={resolvedActor().handle} name={repo.value.name || repo.rkey} description={repo.value.description} + isFork={Boolean(repo.value.source)} showOwner={false} /> )} @@ -1306,6 +1348,7 @@ export const ProfilePage: Component = () => { owner={repo.owner.handle} name={repo.slug} description={repo.record.value.description} + isFork={Boolean(repo.record.value.source)} showOwner={true} /> )} diff --git a/src/pages/repo/pulls-helpers.ts b/src/pages/repo/pulls-helpers.ts index 52daa2b..539f6e3 100644 --- a/src/pages/repo/pulls-helpers.ts +++ b/src/pages/repo/pulls-helpers.ts @@ -1,7 +1,7 @@ import type { SearchParamValue } from '../../lib/repo-utils'; export type PullStateFilter = 'open' | 'closed' | 'merged'; -export type PullSourceMode = 'branch' | 'patch'; +export type PullSourceMode = 'branch' | 'fork' | 'patch'; export interface PullFilter { state: PullStateFilter; diff --git a/src/pages/repo/pulls.tsx b/src/pages/repo/pulls.tsx index 7f7bf7f..b345752 100644 --- a/src/pages/repo/pulls.tsx +++ b/src/pages/repo/pulls.tsx @@ -15,13 +15,14 @@ import { Search, X, } from 'lucide-solid'; +import type { Did } from '@atcute/lexicons/syntax'; import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; import { createQuery, useQueryClient } from '@tanstack/solid-query'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; import { resolveActor } from '../../lib/api/identity'; import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, mergePull, setPullStatus } from '../../lib/api/pulls'; import { parseAtUri } from '../../lib/api/records'; -import { compareBranches, getRepoBranches, type RepoContext } from '../../lib/api/repos'; +import { compareBranches, getRepoBranches, type RepoContext, compareFork, listRepoRecords } from '../../lib/api/repos'; import { useAuth } from '../../lib/auth'; import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; import { AtUriPanel, BranchPill, CommentCard, DiffView, MarkdownBlock, PullComposeSkeleton, PullDiffSkeleton, PullDiffView, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; @@ -244,6 +245,84 @@ export const NewPullPage: Component = () => { const [error, setError] = createSignal(null); const [submitting, setSubmitting] = createSignal(false); + const [selectedForkDid, setSelectedForkDid] = createSignal(''); + const [forkSourceBranch, setForkSourceBranch] = createSignal(''); + + const viewerQuery = createQuery(() => { + return { + queryKey: ['viewer', auth.currentDid()], + enabled: Boolean(auth.currentDid()), + queryFn: async () => resolveActor(auth.currentDid()!), + }; + }); + + const myReposQuery = createQuery(() => { + return { + queryKey: ['myRepos', auth.currentDid()], + enabled: Boolean(auth.currentDid() && sourceMode() === 'fork'), + queryFn: async () => listRepoRecords(auth.currentDid()!), + }; + }); + + const forks = createMemo(() => { + const repos = myReposQuery.data ?? []; + const currentRepo = repoQuery.data; + if (!currentRepo) return []; + return repos.filter((r) => { + const source = r.value.source; + if (!source) return false; + return source === currentRepo.repoDid || source === currentRepo.record.uri; + }); + }); + + const selectedForkRecord = createMemo(() => { + return forks().find((f) => f.value.repoDid === selectedForkDid()); + }); + + createEffect(() => { + const availableForks = forks(); + if (availableForks.length > 0 && !selectedForkDid()) { + setSelectedForkDid(availableForks[0].value.repoDid!); + } + }); + + createEffect(() => { + selectedForkDid(); + setForkSourceBranch(''); + }); + + const selectedForkContext = createMemo(() => { + const forkRec = selectedForkRecord(); + const viewer = viewerQuery.data; + if (!forkRec || !viewer) return null; + return { + owner: viewer, + record: forkRec, + repoDid: forkRec.value.repoDid!, + slug: forkRec.value.name || forkRec.rkey, + rkey: forkRec.rkey, + knot: forkRec.value.knot, + }; + }); + + const forkBranchesQuery = createQuery(() => { + const forkCtx = selectedForkContext(); + return { + queryKey: ['branches', forkCtx?.repoDid], + enabled: Boolean(sourceMode() === 'fork' && forkCtx), + queryFn: async () => getRepoBranches(forkCtx!), + }; + }); + + createEffect(() => { + const branches = forkBranchesQuery.data?.branches ?? []; + if (branches.length === 0) return; + if (!forkSourceBranch()) { + const fallback = branches.find((branch) => branch.is_default)?.reference.name || branches[0].reference.name; + setForkSourceBranch(fallback); + } + }); + const branchesQuery = createQuery(() => { const repo = repoQuery.data; return { @@ -281,6 +360,19 @@ export const NewPullPage: Component = () => { }; }); + const forkCompareQuery = createQuery(() => { + const agent = auth.agent(); + const forkCtx = selectedForkContext(); + const target = targetBranch(); + const source = forkSourceBranch(); + const mode = sourceMode(); + return { + queryKey: ['compare-fork', forkCtx?.repoDid, target, source], + enabled: Boolean(mode === 'fork' && agent && forkCtx && target && source), + queryFn: async () => compareFork(agent!, forkCtx!, target, source), + }; + }); + createEffect(() => { if (sourceMode() !== 'branch') return; const comparison = compareQuery.data; @@ -290,6 +382,15 @@ export const NewPullPage: Component = () => { if (!touchedBody()) setBody(patch.Body || ''); }); + createEffect(() => { + if (sourceMode() !== 'fork') return; + const comparison = forkCompareQuery.data; + if (!comparison || comparison.format_patch.length === 0) return; + const patch = comparison.format_patch[0]; + if (!touchedTitle()) setTitle(patch.Title || ''); + if (!touchedBody()) setBody(patch.Body || ''); + }); + createEffect(() => { if (sourceMode() !== 'patch') return; const prefill = parsePastedPatchPrefill(patchText()); @@ -300,9 +401,21 @@ export const NewPullPage: Component = () => { const pastedPatch = createMemo(() => patchText().trim()); const hasPastedPatch = createMemo(() => sourceMode() === 'patch' && Boolean(pastedPatch())); - const activePatch = createMemo(() => (sourceMode() === 'patch' ? pastedPatch() : compareQuery.data?.patch ?? '')); - const hasComparison = createMemo(() => sourceMode() === 'branch' && Boolean(compareQuery.data)); - const commits = createMemo(() => (sourceMode() === 'branch' ? (compareQuery.data?.format_patch ?? []) : [])); + const activePatch = createMemo(() => { + if (sourceMode() === 'patch') return pastedPatch(); + if (sourceMode() === 'fork') return forkCompareQuery.data?.patch ?? ''; + return compareQuery.data?.patch ?? ''; + }); + const hasComparison = createMemo(() => { + if (sourceMode() === 'branch') return Boolean(compareQuery.data); + if (sourceMode() === 'fork') return Boolean(forkCompareQuery.data); + return false; + }); + const commits = createMemo(() => { + if (sourceMode() === 'branch') return compareQuery.data?.format_patch ?? []; + if (sourceMode() === 'fork') return forkCompareQuery.data?.format_patch ?? []; + return []; + }); const hasCommits = createMemo(() => commits().length > 0); const hasPatch = createMemo(() => Boolean(activePatch())); const showDetails = createMemo(() => hasCommits() || hasPatch()); @@ -329,6 +442,16 @@ export const NewPullPage: Component = () => { return; } + if (mode === 'fork' && !selectedForkDid()) { + setError('Fork selection required.'); + return; + } + + if (mode === 'fork' && !forkSourceBranch()) { + setError('Fork branch required.'); + return; + } + if (!patch) { setError(mode === 'patch' ? 'Paste a patch first.' : 'No patch available for the selected branches.'); return; @@ -341,22 +464,23 @@ export const NewPullPage: Component = () => { setSubmitting(true); setError(null); - try { - const created = await createPull(agent, repo, { - title: title(), - body: body(), - sourceBranch: mode === 'patch' ? targetBranch() : sourceBranch(), - targetBranch: targetBranch(), - patch, - }); - await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) }); - navigate(pullHref(repo, created.uri)); - } catch (cause) { - setError(cause instanceof Error ? cause.message : 'Failed to create pull request'); - } finally { - setSubmitting(false); - } - }; + try { + const created = await createPull(agent, repo, { + title: title(), + body: body(), + sourceBranch: mode === 'patch' ? targetBranch() : (mode === 'fork' ? forkSourceBranch() : sourceBranch()), + targetBranch: targetBranch(), + patch, + sourceRepoDid: mode === 'fork' ? (selectedForkDid() as Did) : undefined, + }); + await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) }); + navigate(pullHref(repo, created.uri)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'Failed to create pull request'); + } finally { + setSubmitting(false); + } + }; return ( }> @@ -377,6 +501,12 @@ export const NewPullPage: Component = () => { description="Select a source branch" onClick={() => setSourceMode('branch')} /> + setSourceMode('fork')} + /> { + + +
+ +
+ + + + +
+
+ + +
+ +
+ + + + +
+
+
+
+ +
+ No forks found for this repository. +
+
+
@@ -519,6 +723,16 @@ index 1234567..abcdefg 100644
+ +
+ +
+
+ +
+ +
+
Paste a patch above to see a comparison. @@ -537,12 +751,22 @@ index 1234567..abcdefg 100644 Pick a source and target above to see a comparison.
+ +
+ Pick a fork and source branch above to see a comparison. +
+
No commits between target and source. Make sure your source branch has commits not on the target.
- + +
+ No commits between target and source. Make sure your source branch has commits not on the target. +
+
+
@@ -551,7 +775,9 @@ index 1234567..abcdefg 100644 {targetBranch()} - {sourceBranch()} + + {sourceMode() === 'fork' ? `${viewerQuery.data?.handle ?? auth.currentDid()}/${selectedForkRecord()?.value.name || selectedForkRecord()?.rkey}:${forkSourceBranch()}` : sourceBranch()} +
diff --git a/src/pages/repo/shared.tsx b/src/pages/repo/shared.tsx index 7cd262f..84f3965 100644 --- a/src/pages/repo/shared.tsx +++ b/src/pages/repo/shared.tsx @@ -1,10 +1,13 @@ -import { CircleDot, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid'; +import { CircleDot, GitFork, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid'; +import type { Did } from '@atcute/lexicons/syntax'; import { A, type RouteSectionProps, useParams } from '@solidjs/router'; import { createQuery } from '@tanstack/solid-query'; import { For, Match, Show, Switch, createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Component, type JSX } from 'solid-js'; import { getRepoIssueCount } from '../../lib/api/issues'; import { getRepoPullCount } from '../../lib/api/pulls'; -import { getRepo } from '../../lib/api/repos'; +import { getRepo, getRepoForkCount, getRepoByDid } from '../../lib/api/repos'; +import { resolveActor } from '../../lib/api/identity'; +import { parseAtUri } from '../../lib/api/records'; import { createRepoStar, deleteRepoStar, getRepoStarSummary } from '../../lib/api/stars'; import { useAuth } from '../../lib/auth'; import { useLiveEvents } from '../../lib/live-events'; @@ -78,6 +81,58 @@ export const RepoFrame: Component<{ enabled: Boolean(repoQuery.data), queryFn: async () => getRepoPullCount(repoQuery.data!), })); + const forkCountQuery = createQuery(() => ({ + queryKey: ['repo-fork-count', repoQuery.data?.repoDid], + enabled: Boolean(repoQuery.data), + queryFn: async () => getRepoForkCount(repoQuery.data!), + })); + + const sourceRepoInfo = createMemo(() => { + const source = repoQuery.data?.record.value.source; + if (!source) return null; + try { + if (source.startsWith('at://')) { + const parsed = parseAtUri(source); + return { + did: parsed.did, + rkey: parsed.rkey, + }; + } + if (source.startsWith('did:')) { + return { + did: source, + rkey: '', + }; + } + } catch { + // ignore + } + return null; + }); + + const sourceActorQuery = createQuery(() => { + const info = sourceRepoInfo(); + return { + queryKey: ['actor', info?.did], + enabled: Boolean(info && info.rkey), + queryFn: async () => resolveActor(info!.did), + }; + }); + + const sourceRepoQuery = createQuery(() => { + const info = sourceRepoInfo(); + return { + queryKey: info ? ['source-repo', info.did, info.rkey] : ['source-repo'], + enabled: Boolean(info), + queryFn: async () => { + if (info!.rkey) { + return getRepo(info!.did, info!.rkey); + } else { + return getRepoByDid(info!.did as Did); + } + }, + }; + }); const starSummaryLoading = createMemo(() => !starSummaryQuery.data && (starSummaryQuery.isLoading || starSummaryQuery.isFetching)); const starBusy = createMemo(() => starWorking() || starSummaryLoading()); createEffect(() => { @@ -215,7 +270,7 @@ export const RepoFrame: Component<{
-
+
+ + {(info) => ( +
+ + forked from + + {info().rkey + ? `${sourceActorQuery.data?.handle ?? info().did}/${info().rkey}` + : info().did} + + } + > + {(source) => ( + + {source().owner.handle}/{source().record.value.name || source().slug} + + )} + +
+ )} +
+
+ + + fork + + + {forkCountQuery.data ?? 0} + + } + > + + + + +