csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

add compare fork new pr, show forked from, resolve repo dids if given in navigation

dawn (May 30, 2026, 4:20 AM +0300) c48c38a5 0a56ed4b

+486 -44
+1 -1
public/oauth-client-metadata.json
··· 3 3 "client_name": "untangled", 4 4 "client_uri": "https://untangled.wisp.place", 5 5 "redirect_uris": ["https://untangled.wisp.place/oauth/callback"], 6 - "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:*/*", 6 + "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:*/*", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",
+6 -6
src/index.css
··· 2321 2321 2322 2322 .untangled-repo-actions { 2323 2323 display: grid; 2324 - grid-template-columns: repeat(2, minmax(0, 1fr)); 2324 + grid-template-columns: repeat(3, minmax(0, 1fr)); 2325 2325 gap: 0.5rem; 2326 2326 width: fit-content; 2327 2327 } ··· 2366 2366 } 2367 2367 2368 2368 .untangled-repo-action-main { 2369 - gap: 0.5rem; 2370 - min-width: 4.75rem; 2371 - padding: 0 0.75rem; 2369 + gap: 0.35rem; 2370 + min-width: 3.5rem; 2371 + padding: 0 0.5rem; 2372 2372 flex: 1 1 auto; 2373 2373 justify-content: center; 2374 2374 position: relative; ··· 2396 2396 border-left: 1px solid rgb(209 213 219); 2397 2397 background: rgb(249 250 251); 2398 2398 justify-content: center; 2399 - min-width: 2.5rem; 2400 - padding: 0 0.75rem; 2399 + min-width: 2rem; 2400 + padding: 0 0.5rem; 2401 2401 position: relative; 2402 2402 text-decoration: none; 2403 2403 }
+3
src/lib/api.ts
··· 32 32 getRepoTags, 33 33 getRepoTree, 34 34 listRepoRecords, 35 + compareFork, 36 + createHiddenRef, 37 + getRepoForkCount, 35 38 } from './api/repos'; 36 39 export { 37 40 createRepoStar,
+3 -1
src/lib/api/pulls.ts
··· 1 1 import { ok } from '@atcute/client'; 2 - import type { ResourceUri } from '@atcute/lexicons/syntax'; 2 + import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; 3 3 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 4 4 import { 5 5 ShTangledRepoMerge, ··· 83 83 targetBranch: string; 84 84 sourceBranch: string; 85 85 patch: string; 86 + sourceRepoDid?: Did; 86 87 } 87 88 88 89 export interface MergePullInput { ··· 362 363 }, 363 364 source: { 364 365 branch: input.sourceBranch, 366 + repo: input.sourceRepoDid, 365 367 }, 366 368 rounds: [ 367 369 {
+55 -1
src/lib/api/repos.ts
··· 11 11 listAllAppviewRecords, 12 12 type AppviewRecordView, 13 13 } from './appview'; 14 - import { getRpc, normalizeServiceUrl } from './client'; 14 + import { getBacklinksPage } from './backlinks'; 15 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 16 + import { getRpc, normalizeServiceUrl, createServiceAuthRpc } from './client'; 15 17 import { REPO_COLLECTION } from './constants'; 16 18 import { resolveActor } from './identity'; 17 19 import { ··· 608 610 609 611 return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; 610 612 }; 613 + 614 + export const createHiddenRef = async ( 615 + agent: OAuthUserAgent, 616 + forkKnot: string, 617 + input: { 618 + forkRef: string; 619 + remoteRef: string; 620 + repo: ResourceUri; 621 + }, 622 + ): Promise<void> => { 623 + const rpc = await createServiceAuthRpc(agent, forkKnot, 'sh.tangled.repo.hiddenRef'); 624 + await ok( 625 + rpc.post('sh.tangled.repo.hiddenRef', { 626 + input: { 627 + forkRef: input.forkRef, 628 + remoteRef: input.remoteRef, 629 + repo: input.repo, 630 + }, 631 + }), 632 + ); 633 + }; 634 + 635 + export const compareFork = async ( 636 + agent: OAuthUserAgent, 637 + forkRepo: RepoContext, 638 + targetBranch: string, 639 + sourceBranch: string, 640 + ): Promise<CompareResponse> => { 641 + await createHiddenRef(agent, forkRepo.knot, { 642 + forkRef: sourceBranch, 643 + remoteRef: targetBranch, 644 + repo: forkRepo.record.uri, 645 + }); 646 + 647 + const hiddenRef = `hidden/${sourceBranch}/${targetBranch}`; 648 + return compareBranches(forkRepo, hiddenRef, sourceBranch); 649 + }; 650 + 651 + export const getRepoForkCount = async (repo: RepoContext): Promise<number> => { 652 + try { 653 + const source = 'sh.tangled.repo:source'; 654 + const [didPage, uriPage] = await Promise.all([ 655 + getBacklinksPage(repo.repoDid, source, 1), 656 + getBacklinksPage(repo.record.uri, source, 1), 657 + ]); 658 + const didCount = didPage.total ?? didPage.records.length; 659 + const uriCount = uriPage.total ?? uriPage.records.length; 660 + return didCount + uriCount; 661 + } catch { 662 + return 0; 663 + } 664 + };
+2
src/lib/repo-utils.ts
··· 23 23 slug: string; 24 24 title: string; 25 25 description?: string; 26 + isFork?: boolean; 26 27 language?: string; 27 28 stars?: number; 28 29 openIssues?: number; ··· 226 227 slug: repo.slug, 227 228 title: `${repo.owner.handle}/${repo.slug}`, 228 229 description: repo.record.value.description?.trim() || undefined, 230 + isFork: Boolean(repo.record.value.source), 229 231 language: existing?.language, 230 232 stars: stats?.stars ?? existing?.stars, 231 233 openIssues: stats?.openIssues ?? existing?.openIssues,
+1
src/pages/home.tsx
··· 513 513 owner={repo.owner} 514 514 name={repo.slug} 515 515 description={repo.description} 516 + isFork={repo.isFork} 516 517 language={repo.language} 517 518 stars={repo.stars} 518 519 openIssues={repo.openIssues}
+52 -9
src/pages/profile.tsx
··· 18 18 Check, 19 19 Pencil, 20 20 } from 'lucide-solid'; 21 - import { A, useParams, useSearchParams } from '@solidjs/router'; 21 + import { A, useParams, useSearchParams, useNavigate } from '@solidjs/router'; 22 22 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 23 23 import { For, Show, Switch, Match, createMemo, createSignal, createEffect, type Component } from 'solid-js'; 24 24 import type { Did } from '@atcute/lexicons/syntax'; ··· 864 864 const queryClient = useQueryClient(); 865 865 const auth = useAuth(); 866 866 867 + const navigate = useNavigate(); 868 + 867 869 const [repoSearch, setRepoSearch] = createSignal(''); 868 870 const [actionLoading, setActionLoading] = createSignal(false); 871 + const [isRedirecting, setIsRedirecting] = createSignal(false); 869 872 870 873 const actorParam = () => params.actor as string; 871 - const activeTab = () => { 872 - const tab = searchParams.tab; 873 - return (Array.isArray(tab) ? tab[0] : tab) || 'overview'; 874 - }; 874 + 875 + createEffect(() => { 876 + actorParam(); 877 + setIsRedirecting(false); 878 + }); 879 + 880 + const isDid = () => actorParam().startsWith('did:'); 875 881 876 882 const actorQuery = createQuery(() => ({ 877 883 queryKey: ['profile-actor', actorParam()], 878 884 queryFn: () => resolveActor(actorParam()), 879 885 })); 880 886 887 + const repoDidQuery = createQuery(() => ({ 888 + queryKey: ['repo-by-did', actorParam()], 889 + enabled: isDid(), 890 + queryFn: () => getRepoByDid(actorParam() as Did), 891 + retry: false, 892 + })); 893 + 894 + createEffect(() => { 895 + const repo = repoDidQuery.data; 896 + if (repo) { 897 + setIsRedirecting(true); 898 + navigate(`/${repo.owner.handle}/${repo.slug}`, { replace: true }); 899 + } 900 + }); 901 + 902 + createEffect(() => { 903 + const err = repoDidQuery.error; 904 + if (err) { 905 + console.error('Failed to resolve DID as repo:', err); 906 + } 907 + }); 908 + 909 + const activeTab = () => { 910 + const tab = searchParams.tab; 911 + return (Array.isArray(tab) ? tab[0] : tab) || 'overview'; 912 + }; 913 + 881 914 const profileQuery = createQuery(() => ({ 882 915 queryKey: ['profile-record', actorQuery.data?.did], 883 916 enabled: !!actorQuery.data, ··· 1094 1127 return ( 1095 1128 <Switch> 1096 1129 <Match when={actorQuery.isLoading}> 1097 - <LoadingState label="Loading profile..." /> 1130 + <LoadingState label={isDid() ? "Resolving identifier..." : "Loading profile..."} /> 1098 1131 </Match> 1099 1132 1100 1133 <Match when={actorQuery.error}> 1101 - <div class="p-6 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800"> 1102 - <ErrorState message="Could not resolve user profile." /> 1103 - </div> 1134 + <Show 1135 + when={isRedirecting() || (isDid() && repoDidQuery.isLoading)} 1136 + fallback={ 1137 + <div class="p-6 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800"> 1138 + <ErrorState message="Could not resolve user profile." /> 1139 + </div> 1140 + } 1141 + > 1142 + <LoadingState label="Resolving repository DID..." /> 1143 + </Show> 1104 1144 </Match> 1105 1145 1106 1146 <Match when={actorQuery.data}> ··· 1210 1250 owner={resolvedActor().handle} 1211 1251 name={repo.value.name || repo.rkey} 1212 1252 description={repo.value.description} 1253 + isFork={Boolean(repo.value.source)} 1213 1254 showOwner={false} 1214 1255 /> 1215 1256 )} ··· 1277 1318 owner={resolvedActor().handle} 1278 1319 name={repo.value.name || repo.rkey} 1279 1320 description={repo.value.description} 1321 + isFork={Boolean(repo.value.source)} 1280 1322 showOwner={false} 1281 1323 /> 1282 1324 )} ··· 1306 1348 owner={repo.owner.handle} 1307 1349 name={repo.slug} 1308 1350 description={repo.record.value.description} 1351 + isFork={Boolean(repo.record.value.source)} 1309 1352 showOwner={true} 1310 1353 /> 1311 1354 )}
+1 -1
src/pages/repo/pulls-helpers.ts
··· 1 1 import type { SearchParamValue } from '../../lib/repo-utils'; 2 2 3 3 export type PullStateFilter = 'open' | 'closed' | 'merged'; 4 - export type PullSourceMode = 'branch' | 'patch'; 4 + export type PullSourceMode = 'branch' | 'fork' | 'patch'; 5 5 6 6 export interface PullFilter { 7 7 state: PullStateFilter;
+248 -22
src/pages/repo/pulls.tsx
··· 15 15 Search, 16 16 X, 17 17 } from 'lucide-solid'; 18 + import type { Did } from '@atcute/lexicons/syntax'; 18 19 import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; 19 20 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 20 21 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 21 22 import { resolveActor } from '../../lib/api/identity'; 22 23 import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, mergePull, setPullStatus } from '../../lib/api/pulls'; 23 24 import { parseAtUri } from '../../lib/api/records'; 24 - import { compareBranches, getRepoBranches, type RepoContext } from '../../lib/api/repos'; 25 + import { compareBranches, getRepoBranches, type RepoContext, compareFork, listRepoRecords } from '../../lib/api/repos'; 25 26 import { useAuth } from '../../lib/auth'; 26 27 import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 27 28 import { AtUriPanel, BranchPill, CommentCard, DiffView, MarkdownBlock, PullComposeSkeleton, PullDiffSkeleton, PullDiffView, RepoListPageSkeleton, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; ··· 244 245 const [error, setError] = createSignal<string | null>(null); 245 246 const [submitting, setSubmitting] = createSignal(false); 246 247 248 + const [selectedForkDid, setSelectedForkDid] = createSignal(''); 249 + const [forkSourceBranch, setForkSourceBranch] = createSignal(''); 250 + 251 + const viewerQuery = createQuery(() => { 252 + return { 253 + queryKey: ['viewer', auth.currentDid()], 254 + enabled: Boolean(auth.currentDid()), 255 + queryFn: async () => resolveActor(auth.currentDid()!), 256 + }; 257 + }); 258 + 259 + const myReposQuery = createQuery(() => { 260 + return { 261 + queryKey: ['myRepos', auth.currentDid()], 262 + enabled: Boolean(auth.currentDid() && sourceMode() === 'fork'), 263 + queryFn: async () => listRepoRecords(auth.currentDid()!), 264 + }; 265 + }); 266 + 267 + const forks = createMemo(() => { 268 + const repos = myReposQuery.data ?? []; 269 + const currentRepo = repoQuery.data; 270 + if (!currentRepo) return []; 271 + return repos.filter((r) => { 272 + const source = r.value.source; 273 + if (!source) return false; 274 + return source === currentRepo.repoDid || source === currentRepo.record.uri; 275 + }); 276 + }); 277 + 278 + const selectedForkRecord = createMemo(() => { 279 + return forks().find((f) => f.value.repoDid === selectedForkDid()); 280 + }); 281 + 282 + createEffect(() => { 283 + const availableForks = forks(); 284 + if (availableForks.length > 0 && !selectedForkDid()) { 285 + setSelectedForkDid(availableForks[0].value.repoDid!); 286 + } 287 + }); 288 + 289 + createEffect(() => { 290 + selectedForkDid(); 291 + setForkSourceBranch(''); 292 + }); 293 + 294 + const selectedForkContext = createMemo<RepoContext | null>(() => { 295 + const forkRec = selectedForkRecord(); 296 + const viewer = viewerQuery.data; 297 + if (!forkRec || !viewer) return null; 298 + return { 299 + owner: viewer, 300 + record: forkRec, 301 + repoDid: forkRec.value.repoDid!, 302 + slug: forkRec.value.name || forkRec.rkey, 303 + rkey: forkRec.rkey, 304 + knot: forkRec.value.knot, 305 + }; 306 + }); 307 + 308 + const forkBranchesQuery = createQuery(() => { 309 + const forkCtx = selectedForkContext(); 310 + return { 311 + queryKey: ['branches', forkCtx?.repoDid], 312 + enabled: Boolean(sourceMode() === 'fork' && forkCtx), 313 + queryFn: async () => getRepoBranches(forkCtx!), 314 + }; 315 + }); 316 + 317 + createEffect(() => { 318 + const branches = forkBranchesQuery.data?.branches ?? []; 319 + if (branches.length === 0) return; 320 + if (!forkSourceBranch()) { 321 + const fallback = branches.find((branch) => branch.is_default)?.reference.name || branches[0].reference.name; 322 + setForkSourceBranch(fallback); 323 + } 324 + }); 325 + 247 326 const branchesQuery = createQuery(() => { 248 327 const repo = repoQuery.data; 249 328 return { ··· 281 360 }; 282 361 }); 283 362 363 + const forkCompareQuery = createQuery(() => { 364 + const agent = auth.agent(); 365 + const forkCtx = selectedForkContext(); 366 + const target = targetBranch(); 367 + const source = forkSourceBranch(); 368 + const mode = sourceMode(); 369 + return { 370 + queryKey: ['compare-fork', forkCtx?.repoDid, target, source], 371 + enabled: Boolean(mode === 'fork' && agent && forkCtx && target && source), 372 + queryFn: async () => compareFork(agent!, forkCtx!, target, source), 373 + }; 374 + }); 375 + 284 376 createEffect(() => { 285 377 if (sourceMode() !== 'branch') return; 286 378 const comparison = compareQuery.data; ··· 291 383 }); 292 384 293 385 createEffect(() => { 386 + if (sourceMode() !== 'fork') return; 387 + const comparison = forkCompareQuery.data; 388 + if (!comparison || comparison.format_patch.length === 0) return; 389 + const patch = comparison.format_patch[0]; 390 + if (!touchedTitle()) setTitle(patch.Title || ''); 391 + if (!touchedBody()) setBody(patch.Body || ''); 392 + }); 393 + 394 + createEffect(() => { 294 395 if (sourceMode() !== 'patch') return; 295 396 const prefill = parsePastedPatchPrefill(patchText()); 296 397 if (!prefill.title) return; ··· 300 401 301 402 const pastedPatch = createMemo(() => patchText().trim()); 302 403 const hasPastedPatch = createMemo(() => sourceMode() === 'patch' && Boolean(pastedPatch())); 303 - const activePatch = createMemo(() => (sourceMode() === 'patch' ? pastedPatch() : compareQuery.data?.patch ?? '')); 304 - const hasComparison = createMemo(() => sourceMode() === 'branch' && Boolean(compareQuery.data)); 305 - const commits = createMemo(() => (sourceMode() === 'branch' ? (compareQuery.data?.format_patch ?? []) : [])); 404 + const activePatch = createMemo(() => { 405 + if (sourceMode() === 'patch') return pastedPatch(); 406 + if (sourceMode() === 'fork') return forkCompareQuery.data?.patch ?? ''; 407 + return compareQuery.data?.patch ?? ''; 408 + }); 409 + const hasComparison = createMemo(() => { 410 + if (sourceMode() === 'branch') return Boolean(compareQuery.data); 411 + if (sourceMode() === 'fork') return Boolean(forkCompareQuery.data); 412 + return false; 413 + }); 414 + const commits = createMemo(() => { 415 + if (sourceMode() === 'branch') return compareQuery.data?.format_patch ?? []; 416 + if (sourceMode() === 'fork') return forkCompareQuery.data?.format_patch ?? []; 417 + return []; 418 + }); 306 419 const hasCommits = createMemo(() => commits().length > 0); 307 420 const hasPatch = createMemo(() => Boolean(activePatch())); 308 421 const showDetails = createMemo(() => hasCommits() || hasPatch()); ··· 329 442 return; 330 443 } 331 444 445 + if (mode === 'fork' && !selectedForkDid()) { 446 + setError('Fork selection required.'); 447 + return; 448 + } 449 + 450 + if (mode === 'fork' && !forkSourceBranch()) { 451 + setError('Fork branch required.'); 452 + return; 453 + } 454 + 332 455 if (!patch) { 333 456 setError(mode === 'patch' ? 'Paste a patch first.' : 'No patch available for the selected branches.'); 334 457 return; ··· 341 464 342 465 setSubmitting(true); 343 466 setError(null); 344 - try { 345 - const created = await createPull(agent, repo, { 346 - title: title(), 347 - body: body(), 348 - sourceBranch: mode === 'patch' ? targetBranch() : sourceBranch(), 349 - targetBranch: targetBranch(), 350 - patch, 351 - }); 352 - await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) }); 353 - navigate(pullHref(repo, created.uri)); 354 - } catch (cause) { 355 - setError(cause instanceof Error ? cause.message : 'Failed to create pull request'); 356 - } finally { 357 - setSubmitting(false); 358 - } 359 - }; 467 + try { 468 + const created = await createPull(agent, repo, { 469 + title: title(), 470 + body: body(), 471 + sourceBranch: mode === 'patch' ? targetBranch() : (mode === 'fork' ? forkSourceBranch() : sourceBranch()), 472 + targetBranch: targetBranch(), 473 + patch, 474 + sourceRepoDid: mode === 'fork' ? (selectedForkDid() as Did) : undefined, 475 + }); 476 + await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) }); 477 + navigate(pullHref(repo, created.uri)); 478 + } catch (cause) { 479 + setError(cause instanceof Error ? cause.message : 'Failed to create pull request'); 480 + } finally { 481 + setSubmitting(false); 482 + } 483 + }; 360 484 361 485 return ( 362 486 <RepoFrame active="pulls" loadingFallback={<PullComposeSkeleton />}> ··· 376 500 title="Compare branches" 377 501 description="Select a source branch" 378 502 onClick={() => setSourceMode('branch')} 503 + /> 504 + <PullSourceTab 505 + active={sourceMode() === 'fork'} 506 + title="Compare forks" 507 + description="Select a fork and branch" 508 + onClick={() => setSourceMode('fork')} 379 509 /> 380 510 <PullSourceTab 381 511 active={sourceMode() === 'patch'} ··· 462 592 </div> 463 593 </div> 464 594 </Show> 595 + 596 + <Show when={sourceMode() === 'fork'}> 597 + <div id="patch-strategy" class="flex flex-col gap-1"> 598 + <label for="forkSelect" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200"> 599 + Pull from 600 + </label> 601 + <div class="flex flex-wrap gap-2 items-center"> 602 + <select 603 + id="forkSelect" 604 + name="fork" 605 + required 606 + value={selectedForkDid()} 607 + onChange={(event) => setSelectedForkDid(event.currentTarget.value)} 608 + class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 609 + > 610 + <option disabled selected={!selectedForkDid()}> 611 + Select a fork 612 + </option> 613 + <For each={forks()}> 614 + {(fork) => ( 615 + <option value={fork.value.repoDid}> 616 + {viewerQuery.data?.handle ?? auth.currentDid()}/{fork.value.name || fork.rkey} 617 + </option> 618 + )} 619 + </For> 620 + </select> 621 + <Show when={myReposQuery.isLoading || viewerQuery.isLoading}> 622 + <LoaderCircle class="size-4 animate-spin text-gray-500 dark:text-gray-400" /> 623 + </Show> 624 + </div> 625 + </div> 626 + 627 + <Show when={selectedForkDid()}> 628 + <div class="flex flex-col gap-1"> 629 + <label for="forkSourceBranch" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200"> 630 + Fork Branch 631 + </label> 632 + <div class="flex flex-wrap gap-2 items-center"> 633 + <select 634 + id="forkSourceBranch" 635 + name="forkSourceBranch" 636 + required 637 + value={forkSourceBranch()} 638 + onChange={(event) => setForkSourceBranch(event.currentTarget.value)} 639 + class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 640 + > 641 + <option disabled selected={!forkSourceBranch()}> 642 + Source branch 643 + </option> 644 + <For each={forkBranchesQuery.data?.branches ?? []}> 645 + {(branch, index) => { 646 + const name = branch.reference.name; 647 + return ( 648 + <option value={name}> 649 + {name} 650 + {index() === 0 ? ' (new)' : ''} 651 + </option> 652 + ); 653 + }} 654 + </For> 655 + </select> 656 + <Show when={forkCompareQuery.isFetching || forkBranchesQuery.isLoading}> 657 + <LoaderCircle class="size-4 animate-spin text-gray-500 dark:text-gray-400" /> 658 + </Show> 659 + </div> 660 + </div> 661 + </Show> 662 + </Show> 465 663 </div> 664 + 665 + <Show when={sourceMode() === 'fork' && forks().length === 0 && !myReposQuery.isLoading && !viewerQuery.isLoading}> 666 + <div class="text-sm text-gray-500 dark:text-gray-400"> 667 + No forks found for this repository. 668 + </div> 669 + </Show> 466 670 467 671 <Show when={sourceMode() === 'patch'}> 468 672 <div id="patch-upload"> ··· 517 721 <Match when={sourceMode() === 'branch' && compareQuery.error}> 518 722 <div class="p-4 border border-red-200 dark:border-red-800 rounded bg-red-50 dark:bg-gray-800"> 519 723 <ErrorState message={getErrorMessage(compareQuery.error)} /> 724 + </div> 725 + </Match> 726 + <Match when={sourceMode() === 'fork' && forkCompareQuery.isLoading}> 727 + <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800"> 728 + <LoadingState label="Generating comparison..." /> 729 + </div> 730 + </Match> 731 + <Match when={sourceMode() === 'fork' && forkCompareQuery.error}> 732 + <div class="p-4 border border-red-200 dark:border-red-800 rounded bg-red-50 dark:bg-gray-800"> 733 + <ErrorState message={getErrorMessage(forkCompareQuery.error)} /> 520 734 </div> 521 735 </Match> 522 736 <Match when={sourceMode() === 'patch' && !hasPastedPatch()}> ··· 537 751 Pick a source and target above to see a comparison. 538 752 </div> 539 753 </Match> 754 + <Match when={sourceMode() === 'fork' && (!selectedForkDid() || !forkSourceBranch())}> 755 + <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400"> 756 + Pick a fork and source branch above to see a comparison. 757 + </div> 758 + </Match> 540 759 <Match when={sourceMode() === 'branch' && hasComparison() && !hasCommits()}> 541 760 <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400"> 542 761 No commits between target and source. Make sure your source branch has commits not on the target. 543 762 </div> 544 763 </Match> 545 - <Match when={sourceMode() === 'branch' && hasCommits()}> 764 + <Match when={sourceMode() === 'fork' && hasComparison() && !hasCommits()}> 765 + <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400"> 766 + No commits between target and source. Make sure your source branch has commits not on the target. 767 + </div> 768 + </Match> 769 + <Match when={(sourceMode() === 'branch' || sourceMode() === 'fork') && hasCommits()}> 546 770 <div class="flex flex-col gap-2"> 547 771 <div class="flex items-center justify-between gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400"> 548 772 <span class="flex-shrink-0"> ··· 551 775 <span class="inline-flex items-center gap-2 font-mono text-gray-600 dark:text-gray-300 truncate"> 552 776 <span>{targetBranch()}</span> 553 777 <ArrowLeftRight class="w-4 h-4 flex-shrink-0" /> 554 - <span>{sourceBranch()}</span> 778 + <span> 779 + {sourceMode() === 'fork' ? `${viewerQuery.data?.handle ?? auth.currentDid()}/${selectedForkRecord()?.value.name || selectedForkRecord()?.rkey}:${forkSourceBranch()}` : sourceBranch()} 780 + </span> 555 781 </span> 556 782 </div> 557 783
+114 -3
src/pages/repo/shared.tsx
··· 1 - import { CircleDot, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid'; 1 + import { CircleDot, GitFork, GitPullRequest, Globe, LoaderCircle, Rss, SquareChartGantt, Star } from 'lucide-solid'; 2 + import type { Did } from '@atcute/lexicons/syntax'; 2 3 import { A, type RouteSectionProps, useParams } from '@solidjs/router'; 3 4 import { createQuery } from '@tanstack/solid-query'; 4 5 import { For, Match, Show, Switch, createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Component, type JSX } from 'solid-js'; 5 6 import { getRepoIssueCount } from '../../lib/api/issues'; 6 7 import { getRepoPullCount } from '../../lib/api/pulls'; 7 - import { getRepo } from '../../lib/api/repos'; 8 + import { getRepo, getRepoForkCount, getRepoByDid } from '../../lib/api/repos'; 9 + import { resolveActor } from '../../lib/api/identity'; 10 + import { parseAtUri } from '../../lib/api/records'; 8 11 import { createRepoStar, deleteRepoStar, getRepoStarSummary } from '../../lib/api/stars'; 9 12 import { useAuth } from '../../lib/auth'; 10 13 import { useLiveEvents } from '../../lib/live-events'; ··· 78 81 enabled: Boolean(repoQuery.data), 79 82 queryFn: async () => getRepoPullCount(repoQuery.data!), 80 83 })); 84 + const forkCountQuery = createQuery(() => ({ 85 + queryKey: ['repo-fork-count', repoQuery.data?.repoDid], 86 + enabled: Boolean(repoQuery.data), 87 + queryFn: async () => getRepoForkCount(repoQuery.data!), 88 + })); 89 + 90 + const sourceRepoInfo = createMemo(() => { 91 + const source = repoQuery.data?.record.value.source; 92 + if (!source) return null; 93 + try { 94 + if (source.startsWith('at://')) { 95 + const parsed = parseAtUri(source); 96 + return { 97 + did: parsed.did, 98 + rkey: parsed.rkey, 99 + }; 100 + } 101 + if (source.startsWith('did:')) { 102 + return { 103 + did: source, 104 + rkey: '', 105 + }; 106 + } 107 + } catch { 108 + // ignore 109 + } 110 + return null; 111 + }); 112 + 113 + const sourceActorQuery = createQuery(() => { 114 + const info = sourceRepoInfo(); 115 + return { 116 + queryKey: ['actor', info?.did], 117 + enabled: Boolean(info && info.rkey), 118 + queryFn: async () => resolveActor(info!.did), 119 + }; 120 + }); 121 + 122 + const sourceRepoQuery = createQuery(() => { 123 + const info = sourceRepoInfo(); 124 + return { 125 + queryKey: info ? ['source-repo', info.did, info.rkey] : ['source-repo'], 126 + enabled: Boolean(info), 127 + queryFn: async () => { 128 + if (info!.rkey) { 129 + return getRepo(info!.did, info!.rkey); 130 + } else { 131 + return getRepoByDid(info!.did as Did); 132 + } 133 + }, 134 + }; 135 + }); 81 136 const starSummaryLoading = createMemo(() => !starSummaryQuery.data && (starSummaryQuery.isLoading || starSummaryQuery.isFetching)); 82 137 const starBusy = createMemo(() => starWorking() || starSummaryLoading()); 83 138 createEffect(() => { ··· 215 270 <div class={props.fullWidth ? 'untangled-repo-frame-full' : undefined}> 216 271 <section id="repo-header" class="mb-2 py-2 px-4"> 217 272 <div class="flex flex-col sm:flex-row items-start gap-4 justify-between mb-2"> 218 - <div class="flex gap-2"> 273 + <div class="flex flex-col gap-0.5"> 219 274 <div class="flex items-center gap-2 flex-wrap text-lg"> 220 275 <Avatar did={repo().owner.did} size="size-8" /> 221 276 <A href={`/${repo().owner.handle}`} class="font-medium"> ··· 226 281 {repo().slug} 227 282 </A> 228 283 </div> 284 + <Show when={sourceRepoInfo()}> 285 + {(info) => ( 286 + <div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 ml-10"> 287 + <GitFork class="size-3 shrink-0" /> 288 + <span>forked from</span> 289 + <Show 290 + when={sourceRepoQuery.data} 291 + fallback={ 292 + <span class="font-semibold"> 293 + {info().rkey 294 + ? `${sourceActorQuery.data?.handle ?? info().did}/${info().rkey}` 295 + : info().did} 296 + </span> 297 + } 298 + > 299 + {(source) => ( 300 + <A 301 + href={`/${source().owner.handle}/${source().slug}`} 302 + class="hover:underline font-semibold" 303 + > 304 + {source().owner.handle}/{source().record.value.name || source().slug} 305 + </A> 306 + )} 307 + </Show> 308 + </div> 309 + )} 310 + </Show> 229 311 </div> 230 312 231 313 <div class="hidden sm:block sm:flex-shrink-0"> ··· 302 384 > 303 385 <span class="untangled-repo-action-count" aria-disabled="true"> 304 386 - 387 + </span> 388 + </Show> 389 + </div> 390 + <div class="untangled-repo-action"> 391 + <a 392 + href={`https://tangled.org/${repo().owner.handle}/${repo().slug}/fork`} 393 + target="_blank" 394 + rel="noreferrer" 395 + class="untangled-repo-action-main" 396 + > 397 + <GitFork class="size-4" /> 398 + <span>fork</span> 399 + </a> 400 + <Show 401 + when={forkCountQuery.isLoading} 402 + fallback={ 403 + <a 404 + href={`https://tangled.org/${repo().owner.handle}/${repo().slug}/forks`} 405 + target="_blank" 406 + rel="noreferrer" 407 + class="untangled-repo-action-count" 408 + title="Forked by" 409 + > 410 + {forkCountQuery.data ?? 0} 411 + </a> 412 + } 413 + > 414 + <span class="untangled-repo-action-count" aria-disabled="true"> 415 + <LoaderCircle class="size-3 animate-spin" /> 305 416 </span> 306 417 </Show> 307 418 </div>