csr tangled client in solid-js
15

Configure Feed

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

pr merging

dawn (May 22, 2026, 8:42 AM +0300) 2df8057a 4062cbe6

+566 -39
+168 -3
src/components/repo.tsx
··· 2 2 import DOMPurify from 'dompurify'; 3 3 import hljs from 'highlight.js/lib/common'; 4 4 import { 5 + Check, 5 6 ChevronRight, 6 7 Circle, 7 8 Columns2, 8 9 Copy, 10 + Download, 9 11 ExternalLink, 10 12 FileText, 11 13 FoldVertical, ··· 14 16 PanelLeftOpen, 15 17 PanelRightClose, 16 18 PanelRightOpen, 19 + Square, 20 + SquareCheckBig, 17 21 UnfoldVertical, 18 22 } from 'lucide-solid'; 19 23 import { marked } from 'marked'; 20 24 import { A } from '@solidjs/router'; 21 - import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 22 - import type { TreeEntry } from '../lib/api/repos'; 25 + import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 26 + import type { RepoContext, TreeEntry } from '../lib/api/repos'; 23 27 import { formatRelativeTime } from '../lib/repo-utils'; 24 - import { Avatar, SkeletonBlock, cardStyles } from './common'; 28 + import { getTangledAppviewService } from '../lib/settings'; 29 + import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common'; 25 30 26 31 marked.setOptions({ 27 32 gfm: true, ··· 500 505 </section> 501 506 </div> 502 507 ); 508 + 509 + const cloneSshHost = (repo: RepoContext): string => { 510 + try { 511 + const host = new URL(repo.knot).hostname; 512 + return host === 'knot1.tangled.sh' ? 'tangled.org' : host; 513 + } catch { 514 + const host = repo.knot.replace(/^https?:\/\//, '').split('/')[0].replace(/:\d+$/, ''); 515 + return host === 'knot1.tangled.sh' ? 'tangled.org' : host; 516 + } 517 + }; 518 + 519 + const appviewArchiveUrl = (repo: RepoContext, refName: string): string => { 520 + const owner = encodeURIComponent(repo.owner.handle); 521 + const slug = encodeURIComponent(repo.slug); 522 + const ref = encodeURIComponent(refName); 523 + return new URL(`/${owner}/${slug}/archive/${ref}`, getTangledAppviewService()).toString(); 524 + }; 525 + 526 + const selectContents = (element: HTMLElement) => { 527 + const selection = window.getSelection(); 528 + if (!selection) return; 529 + 530 + const range = document.createRange(); 531 + range.selectNodeContents(element); 532 + selection.removeAllRanges(); 533 + selection.addRange(range); 534 + }; 535 + 536 + const CloneUrlItem: Component<{ 537 + label: string; 538 + handleUrl: string; 539 + permaUrl: string; 540 + permalink: boolean; 541 + }> = (props) => { 542 + const [copied, setCopied] = createSignal(false); 543 + const visibleUrl = createMemo(() => (props.permalink ? props.permaUrl : props.handleUrl)); 544 + let copyTimer: ReturnType<typeof setTimeout> | undefined; 545 + 546 + onCleanup(() => clearTimeout(copyTimer)); 547 + 548 + const copy = () => { 549 + clearTimeout(copyTimer); 550 + void navigator.clipboard?.writeText(visibleUrl()); 551 + setCopied(true); 552 + copyTimer = setTimeout(() => setCopied(false), 2000); 553 + }; 554 + 555 + return ( 556 + <div class="mt-4"> 557 + <label class="mb-1 block text-xs font-medium text-gray-700 normal-case dark:text-gray-300"> 558 + {props.label} 559 + </label> 560 + <div class="untangled-clone-url-row"> 561 + <span 562 + class="untangled-clone-url-text" 563 + onClick={(event) => selectContents(event.currentTarget)} 564 + title={visibleUrl()} 565 + > 566 + {visibleUrl()} 567 + </span> 568 + <button 569 + type="button" 570 + class="untangled-clone-copy" 571 + title="Copy to clipboard" 572 + aria-label={`Copy ${props.label} clone URL`} 573 + onClick={copy} 574 + > 575 + <Show when={copied()} fallback={<Copy class="size-4" />}> 576 + <Check class="size-4" /> 577 + </Show> 578 + </button> 579 + </div> 580 + </div> 581 + ); 582 + }; 583 + 584 + export const CloneDropdown: Component<{ repo: RepoContext; refName: string }> = (props) => { 585 + const [open, setOpen] = createSignal(false); 586 + const [permalink, setPermalink] = createSignal(false); 587 + const sshHost = createMemo(() => cloneSshHost(props.repo)); 588 + let root: HTMLDivElement | undefined; 589 + 590 + onMount(() => { 591 + const closeOnOutsideClick = (event: MouseEvent) => { 592 + if (!open() || !root || root.contains(event.target as Node)) return; 593 + setOpen(false); 594 + }; 595 + const closeOnEscape = (event: KeyboardEvent) => { 596 + if (event.key === 'Escape') setOpen(false); 597 + }; 598 + 599 + document.addEventListener('mousedown', closeOnOutsideClick); 600 + document.addEventListener('keydown', closeOnEscape); 601 + onCleanup(() => { 602 + document.removeEventListener('mousedown', closeOnOutsideClick); 603 + document.removeEventListener('keydown', closeOnEscape); 604 + }); 605 + }); 606 + 607 + return ( 608 + <div class="untangled-clone-dropdown" ref={root}> 609 + <button 610 + type="button" 611 + class={clsx(buttonStyles('primary'), 'cursor-pointer px-4')} 612 + aria-expanded={open()} 613 + aria-haspopup="dialog" 614 + onClick={() => setOpen(!open())} 615 + > 616 + <Download class="size-4" /> 617 + <span class="hidden md:inline">code</span> 618 + </button> 619 + <Show when={open()}> 620 + <div class="untangled-clone-popover" role="dialog" aria-label="Clone this repository"> 621 + <div class="flex items-center justify-between gap-3"> 622 + <h3 class="m-0 text-sm font-semibold text-gray-900 dark:text-white"> 623 + Clone this repository 624 + </h3> 625 + <label class="flex cursor-pointer items-center gap-1 text-xs font-normal normal-case text-gray-700 dark:text-gray-300"> 626 + <input 627 + type="checkbox" 628 + class="sr-only" 629 + checked={permalink()} 630 + onChange={(event) => setPermalink(event.currentTarget.checked)} 631 + /> 632 + <Show when={permalink()} fallback={<Square class="size-4" />}> 633 + <SquareCheckBig class="size-4" /> 634 + </Show> 635 + <span>Use permalink</span> 636 + </label> 637 + </div> 638 + 639 + <CloneUrlItem 640 + label="HTTPS" 641 + handleUrl={`https://tangled.org/${props.repo.owner.handle}/${props.repo.slug}`} 642 + permaUrl={`https://tangled.org/${props.repo.repoDid}`} 643 + permalink={permalink()} 644 + /> 645 + <CloneUrlItem 646 + label="SSH" 647 + handleUrl={`git@${sshHost()}:${props.repo.owner.handle}/${props.repo.slug}`} 648 + permaUrl={`git@${sshHost()}:${props.repo.repoDid}`} 649 + permalink={permalink()} 650 + /> 651 + 652 + <p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> 653 + For self-hosted knots, clone URLs may differ based on your setup. 654 + </p> 655 + 656 + <a 657 + href={appviewArchiveUrl(props.repo, props.refName)} 658 + class={clsx(buttonStyles('primary'), 'mt-4 flex w-full justify-center text-sm hover:text-gray-100')} 659 + > 660 + <Download class="size-4" /> 661 + Download tar.gz 662 + </a> 663 + </div> 664 + </Show> 665 + </div> 666 + ); 667 + }; 503 668 504 669 export const CommentCard: Component<{ 505 670 author: string;
+84
src/index.css
··· 1040 1040 min-height: 30px; 1041 1041 } 1042 1042 1043 + .untangled-clone-dropdown { 1044 + position: relative; 1045 + display: inline-flex; 1046 + } 1047 + 1048 + .untangled-clone-popover { 1049 + position: absolute; 1050 + top: calc(100% + 0.5rem); 1051 + right: 0; 1052 + z-index: 70; 1053 + width: min(calc(100vw - 2rem), 27.5rem); 1054 + overflow: visible; 1055 + border: 1px solid rgb(229 231 235); 1056 + border-radius: 0.25rem; 1057 + background: rgb(255 255 255); 1058 + padding: 1rem; 1059 + color: rgb(17 24 39); 1060 + box-shadow: 1061 + 0 10px 15px -3px rgb(0 0 0 / 0.1), 1062 + 0 4px 6px -4px rgb(0 0 0 / 0.1); 1063 + } 1064 + 1065 + .untangled-clone-url-row { 1066 + display: flex; 1067 + align-items: stretch; 1068 + overflow: hidden; 1069 + border: 1px solid rgb(209 213 219); 1070 + border-radius: 0.25rem; 1071 + } 1072 + 1073 + .untangled-clone-url-text { 1074 + min-width: 0; 1075 + flex: 1; 1076 + overflow-x: auto; 1077 + background: rgb(229 231 235); 1078 + padding: 0.5rem 0.75rem; 1079 + color: rgb(17 24 39); 1080 + font-family: IBMPlexMono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; 1081 + font-size: 0.875rem; 1082 + white-space: nowrap; 1083 + cursor: pointer; 1084 + user-select: all; 1085 + } 1086 + 1087 + .untangled-clone-copy { 1088 + display: inline-flex; 1089 + align-items: center; 1090 + justify-content: center; 1091 + border: 0; 1092 + border-left: 1px solid rgb(209 213 219); 1093 + background: rgb(255 255 255); 1094 + padding: 0 0.75rem; 1095 + color: rgb(107 114 128); 1096 + } 1097 + 1098 + .untangled-clone-copy:hover { 1099 + color: rgb(55 65 81); 1100 + } 1101 + 1043 1102 .untangled-input { 1044 1103 min-height: 38px; 1045 1104 } ··· 2057 2116 2058 2117 .untangled-button:not(.btn-create):hover { 2059 2118 background: rgb(55 65 81 / 0.4); 2119 + } 2120 + 2121 + .untangled-clone-popover { 2122 + border-color: rgb(55 65 81); 2123 + background: rgb(31 41 55); 2124 + color: rgb(243 244 246); 2125 + } 2126 + 2127 + .untangled-clone-url-row { 2128 + border-color: rgb(75 85 99); 2129 + } 2130 + 2131 + .untangled-clone-url-text { 2132 + background: rgb(55 65 81); 2133 + color: rgb(243 244 246); 2134 + } 2135 + 2136 + .untangled-clone-copy { 2137 + border-left-color: rgb(75 85 99); 2138 + background: rgb(31 41 55); 2139 + color: rgb(156 163 175); 2140 + } 2141 + 2142 + .untangled-clone-copy:hover { 2143 + color: rgb(229 231 235); 2060 2144 } 2061 2145 2062 2146 .untangled-blob-card {
+2
src/lib/api.ts
··· 58 58 getRepoPullCount, 59 59 listPulls, 60 60 listPullsPage, 61 + mergePull, 61 62 setPullStatus, 62 63 } from './api/pulls'; 63 64 export { ··· 105 106 } from './api/issues'; 106 107 export type { 107 108 CreatePullInput, 109 + MergePullInput, 108 110 PullComment, 109 111 PullDetail, 110 112 PullSummary,
+36 -1
src/lib/api/client.ts
··· 1 - import { Client, simpleFetchHandler } from '@atcute/client'; 1 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 + import type { Nsid } from '@atcute/lexicons/syntax'; 2 3 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 3 4 4 5 const rpcCache = new Map<string, Client>(); ··· 32 33 new Client({ 33 34 handler: agent, 34 35 }); 36 + 37 + const serviceAudience = (service: string): `did:web:${string}` => { 38 + const url = new URL(normalizeServiceUrl(service)); 39 + return `did:web:${url.host}`; 40 + }; 41 + 42 + export const createServiceAuthRpc = async ( 43 + agent: OAuthUserAgent, 44 + service: string, 45 + lxm: Nsid, 46 + ): Promise<Client> => { 47 + const authRpc = createAuthRpc(agent); 48 + const { token } = await ok( 49 + authRpc.get('com.atproto.server.getServiceAuth', { 50 + params: { 51 + aud: serviceAudience(service), 52 + exp: Math.floor(Date.now() / 1000) + 60, 53 + lxm, 54 + }, 55 + }), 56 + ); 57 + const serviceUrl = normalizeServiceUrl(service); 58 + 59 + return new Client({ 60 + handler: async (pathname, init) => { 61 + const headers = new Headers(init.headers); 62 + headers.set('authorization', `Bearer ${token}`); 63 + return fetch(new URL(pathname, serviceUrl), { 64 + ...init, 65 + headers, 66 + }); 67 + }, 68 + }); 69 + };
+34 -1
src/lib/api/pulls.ts
··· 2 2 import type { ResourceUri } from '@atcute/lexicons/syntax'; 3 3 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 4 4 import { 5 + ShTangledRepoMerge, 5 6 ShTangledRepoPull, 6 7 ShTangledRepoPullComment, 7 8 ShTangledRepoPullStatus, ··· 28 29 PULL_COMMENT_COLLECTION, 29 30 PULL_STATUS_COLLECTION, 30 31 } from './constants'; 31 - import { getRpc } from './client'; 32 + import { createServiceAuthRpc, getRpc } from './client'; 32 33 import { 33 34 appviewRecordToHydrated, 34 35 fetchRecordValue, ··· 81 82 body: string; 82 83 targetBranch: string; 83 84 sourceBranch: string; 85 + patch: string; 86 + } 87 + 88 + export interface MergePullInput { 89 + pull: PullSummary; 84 90 patch: string; 85 91 } 86 92 ··· 447 453 rkey: parseAtUri(result.uri).rkey, 448 454 }; 449 455 }; 456 + 457 + export const mergePull = async ( 458 + agent: OAuthUserAgent, 459 + repo: RepoContext, 460 + input: MergePullInput, 461 + ): Promise<RecordCreationResult> => { 462 + const rpc = await createServiceAuthRpc(agent, repo.knot, 'sh.tangled.repo.merge'); 463 + const pull = input.pull; 464 + const mergeInput: ShTangledRepoMerge.$input = { 465 + did: repo.owner.did, 466 + name: repo.slug, 467 + branch: pull.value.target.branch, 468 + patch: input.patch, 469 + commitMessage: pull.value.title, 470 + authorName: pull.author.handle, 471 + ...(pull.value.body ? { commitBody: pull.value.body } : {}), 472 + }; 473 + 474 + await ok( 475 + rpc.post('sh.tangled.repo.merge', { 476 + input: mergeInput, 477 + as: null, 478 + }), 479 + ); 480 + 481 + return setPullStatus(agent, pull.uri, 'merged'); 482 + };
+3 -6
src/pages/repo/code.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { Download, File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid'; 2 + import { File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid'; 3 3 import { A, useNavigate, useParams } from '@solidjs/router'; 4 4 import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query'; 5 5 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 6 6 import { buildBlobDataUrl, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree } from '../../lib/api/repos'; 7 7 import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; 8 - import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo'; 8 + import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo'; 9 9 import { RepoFrame, useRepoQuery } from './shared'; 10 10 import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, videoLike } from '../../lib/repo-utils'; 11 11 import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers'; ··· 636 636 </button> 637 637 </div> 638 638 639 - <a href={treeHref(repo, activeRef, path())} class={clsx(buttonStyles('primary'), 'bg-green-700 border-green-700 hover:bg-green-800 hover:border-green-800')}> 640 - <Download class="size-4" /> 641 - code 642 - </a> 639 + <CloneDropdown repo={repo} refName={activeRef} /> 643 640 </div> 644 641 645 642 <Show when={isNestedTreePath}>
+92 -14
src/pages/repo/issues.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, Reply, Search, Smile, X } from 'lucide-solid'; 2 + import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, RefreshCcwDot, Reply, Search, Smile, X } from 'lucide-solid'; 3 3 import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import type { ResourceUri } from '@atcute/lexicons/syntax'; ··· 264 264 ); 265 265 }; 266 266 267 + const updateIssueStateData = ( 268 + data: unknown, 269 + issueUri: ResourceUri, 270 + state: 'open' | 'closed', 271 + ): unknown => { 272 + if (!data || typeof data !== 'object') return data; 273 + 274 + if ('issue' in data) { 275 + const detail = data as { issue?: { uri?: string; state?: string } }; 276 + if (detail.issue?.uri === issueUri) { 277 + return { 278 + ...detail, 279 + issue: { 280 + ...detail.issue, 281 + state, 282 + }, 283 + }; 284 + } 285 + } 286 + 287 + if ('items' in data) { 288 + const page = data as { items?: Array<{ uri?: string; state?: string }> }; 289 + if (Array.isArray(page.items)) { 290 + return { 291 + ...page, 292 + items: page.items.map((issue) => (issue.uri === issueUri ? { ...issue, state } : issue)), 293 + }; 294 + } 295 + } 296 + 297 + return data; 298 + }; 299 + 267 300 export const IssuePage: Component = () => { 268 301 const auth = useAuth(); 269 302 const repoQuery = useRepoQuery(); ··· 273 306 const [comment, setComment] = createSignal(''); 274 307 const [replyingTo, setReplyingTo] = createSignal<ResourceUri | null>(null); 275 308 const [replyBody, setReplyBody] = createSignal(''); 276 - const [working, setWorking] = createSignal(false); 309 + const [workingAction, setWorkingAction] = createSignal<'comment' | 'reply' | 'state' | null>(null); 310 + const working = createMemo(() => workingAction() !== null); 277 311 const [error, setError] = createSignal<string | null>(null); 278 312 279 313 const issueQuery = createQuery(() => { ··· 291 325 await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(issueUri).rkey) }); 292 326 }; 293 327 328 + const updateIssueStateCaches = ( 329 + repo: RepoContext, 330 + currentIssueRef: string, 331 + issueUri: ResourceUri, 332 + state: 'open' | 'closed', 333 + ) => { 334 + client.setQueryData(issueQueryKey(repo.repoDid, currentIssueRef), (data: unknown) => 335 + updateIssueStateData(data, issueUri, state), 336 + ); 337 + client.setQueryData(issueQueryKey(repo.repoDid, parseAtUri(issueUri).rkey), (data: unknown) => 338 + updateIssueStateData(data, issueUri, state), 339 + ); 340 + client.setQueriesData<unknown>({ queryKey: issuesQueryKey(repo.repoDid) }, (data: unknown) => 341 + updateIssueStateData(data, issueUri, state), 342 + ); 343 + }; 344 + 294 345 const toggleState = async () => { 295 346 const agent = auth.agent(); 296 347 const repo = repoQuery.data; ··· 299 350 return; 300 351 } 301 352 302 - setWorking(true); 353 + const nextState = detail.issue.state === 'open' ? 'closed' : 'open'; 354 + const commentBody = nextState === 'closed' ? comment().trim() : ''; 355 + 356 + setWorkingAction('state'); 303 357 setError(null); 304 358 try { 305 - await setIssueState(agent, detail.issue.uri, detail.issue.state === 'open' ? 'closed' : 'open'); 306 - await withIssueInvalidation(repo, issueRef(), detail.issue.uri); 359 + if (commentBody) { 360 + await createIssueComment(agent, detail.issue.uri, commentBody); 361 + setComment(''); 362 + } 363 + await setIssueState(agent, detail.issue.uri, nextState); 364 + if (commentBody) { 365 + await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef()) }); 366 + await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(detail.issue.uri).rkey) }); 367 + } 368 + updateIssueStateCaches(repo, issueRef(), detail.issue.uri, nextState); 369 + await client.invalidateQueries({ queryKey: issuesQueryKey(repo.repoDid), refetchType: 'none' }); 370 + await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef()), refetchType: 'none' }); 371 + await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(detail.issue.uri).rkey), refetchType: 'none' }); 307 372 } catch (cause) { 308 373 setError(cause instanceof Error ? cause.message : 'Failed to update issue state'); 309 374 } finally { 310 - setWorking(false); 375 + setWorkingAction(null); 311 376 } 312 377 }; 313 378 ··· 320 385 return; 321 386 } 322 387 323 - setWorking(true); 388 + setWorkingAction('comment'); 324 389 setError(null); 325 390 try { 326 391 await createIssueComment(agent, detail.issue.uri, comment()); ··· 329 394 } catch (cause) { 330 395 setError(cause instanceof Error ? cause.message : 'Failed to create comment'); 331 396 } finally { 332 - setWorking(false); 397 + setWorkingAction(null); 333 398 } 334 399 }; 335 400 ··· 342 407 return; 343 408 } 344 409 345 - setWorking(true); 410 + setWorkingAction('reply'); 346 411 setError(null); 347 412 try { 348 413 await createIssueComment(agent, detail.issue.uri, replyBody(), replyTo); ··· 352 417 } catch (cause) { 353 418 setError(cause instanceof Error ? cause.message : 'Failed to create reply'); 354 419 } finally { 355 - setWorking(false); 420 + setWorkingAction(null); 356 421 } 357 422 }; 358 423 ··· 516 581 cancel 517 582 </button> 518 583 <button type="submit" class={buttonStyles('primary')} disabled={working() || !replyBody().trim()}> 519 - <Reply class="size-4" /> 584 + <Show when={workingAction() === 'reply'} fallback={<Reply class="size-4" />}> 585 + <LoaderCircle class="size-4 animate-spin" /> 586 + </Show> 520 587 reply 521 588 </button> 522 589 </div> ··· 551 618 </div> 552 619 <div class="flex flex-wrap items-center gap-2"> 553 620 <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working() || !comment().trim()}> 554 - <MessageSquarePlus class="size-4" /> 621 + <Show when={workingAction() === 'comment'} fallback={<MessageSquarePlus class="size-4" />}> 622 + <LoaderCircle class="size-4 animate-spin" /> 623 + </Show> 555 624 comment 556 625 </button> 557 626 <button type="button" class={buttonStyles()} disabled={working()} onClick={() => void toggleState()}> 558 - <Ban class="size-4" /> 559 - {detail().issue.state === 'open' ? 'close' : 'reopen'} 627 + <Show 628 + when={workingAction() === 'state'} 629 + fallback={ 630 + <Show when={detail().issue.state === 'open'} fallback={<RefreshCcwDot class="size-4" />}> 631 + <Ban class="size-4" /> 632 + </Show> 633 + } 634 + > 635 + <LoaderCircle class="size-4 animate-spin" /> 636 + </Show> 637 + {detail().issue.state === 'open' ? (comment().trim() ? 'close with comment' : 'close') : 'reopen'} 560 638 </button> 561 639 </div> 562 640 </form>
+147 -14
src/pages/repo/pulls.tsx
··· 11 11 LoaderCircle, 12 12 MessageSquare, 13 13 Pencil, 14 + RefreshCcwDot, 14 15 Search, 15 16 X, 16 17 } from 'lucide-solid'; ··· 18 19 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 19 20 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 20 21 import { resolveActor } from '../../lib/api/identity'; 21 - import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, setPullStatus } from '../../lib/api/pulls'; 22 + import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, mergePull, setPullStatus } from '../../lib/api/pulls'; 22 23 import { parseAtUri } from '../../lib/api/records'; 23 24 import { compareBranches, getRepoBranches, type RepoContext } from '../../lib/api/repos'; 24 25 import { useAuth } from '../../lib/auth'; ··· 745 746 </button> 746 747 ); 747 748 749 + const updatePullStatusData = ( 750 + data: unknown, 751 + pullUri: string, 752 + state: 'open' | 'closed' | 'merged', 753 + ): unknown => { 754 + if (!data || typeof data !== 'object') return data; 755 + 756 + if ('pull' in data) { 757 + const detail = data as { pull?: { uri?: string; state?: string } }; 758 + if (detail.pull?.uri === pullUri) { 759 + return { 760 + ...detail, 761 + pull: { 762 + ...detail.pull, 763 + state, 764 + }, 765 + }; 766 + } 767 + } 768 + 769 + if ('items' in data) { 770 + const page = data as { items?: Array<{ uri?: string; state?: string }> }; 771 + if (Array.isArray(page.items)) { 772 + return { 773 + ...page, 774 + items: page.items.map((pull) => (pull.uri === pullUri ? { ...pull, state } : pull)), 775 + }; 776 + } 777 + } 778 + 779 + return data; 780 + }; 781 + 748 782 export const PullPage: Component = () => { 749 783 const auth = useAuth(); 750 784 const repoQuery = useRepoQuery(); ··· 753 787 const pullRef = createMemo(() => params.pullRef ?? ''); 754 788 const [roundIndex, setRoundIndex] = createSignal(0); 755 789 const [comment, setComment] = createSignal(''); 756 - const [working, setWorking] = createSignal(false); 790 + const [workingAction, setWorkingAction] = createSignal<'comment' | 'state' | 'merge' | null>(null); 791 + const working = createMemo(() => workingAction() !== null); 757 792 const [error, setError] = createSignal<string | null>(null); 758 793 759 794 const pullQuery = createQuery(() => { ··· 787 822 await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey) }); 788 823 }; 789 824 825 + const updatePullStatusCaches = ( 826 + repo: RepoContext, 827 + currentPullRef: string, 828 + pullUri: string, 829 + state: 'open' | 'closed' | 'merged', 830 + ) => { 831 + client.setQueryData(pullQueryKey(repo.repoDid, currentPullRef), (data: unknown) => 832 + updatePullStatusData(data, pullUri, state), 833 + ); 834 + client.setQueryData(pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey), (data: unknown) => 835 + updatePullStatusData(data, pullUri, state), 836 + ); 837 + client.setQueriesData<unknown>({ queryKey: pullsQueryKey(repo.repoDid) }, (data: unknown) => 838 + updatePullStatusData(data, pullUri, state), 839 + ); 840 + }; 841 + 842 + const markPullQueriesStale = async (repo: RepoContext, currentPullRef: string, pullUri: string) => { 843 + await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid), refetchType: 'none' }); 844 + await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, currentPullRef), refetchType: 'none' }); 845 + await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey), refetchType: 'none' }); 846 + }; 847 + 790 848 const toggleState = async () => { 791 849 const agent = auth.agent(); 792 850 const repo = repoQuery.data; ··· 795 853 return; 796 854 } 797 855 798 - setWorking(true); 856 + const nextState = detail.pull.state === 'open' ? 'closed' : 'open'; 857 + setWorkingAction('state'); 799 858 setError(null); 800 859 try { 801 - await setPullStatus(agent, detail.pull.uri, detail.pull.state === 'open' ? 'closed' : 'open'); 802 - await invalidate(repo, pullRef(), detail.pull.uri); 860 + await setPullStatus(agent, detail.pull.uri, nextState); 861 + updatePullStatusCaches(repo, pullRef(), detail.pull.uri, nextState); 862 + await markPullQueriesStale(repo, pullRef(), detail.pull.uri); 803 863 } catch (cause) { 804 864 setError(cause instanceof Error ? cause.message : 'Failed to update pull state'); 805 865 } finally { 806 - setWorking(false); 866 + setWorkingAction(null); 867 + } 868 + }; 869 + 870 + const mergeCurrentPull = async () => { 871 + const agent = auth.agent(); 872 + const repo = repoQuery.data; 873 + const detail = pullQuery.data; 874 + if (!agent || !repo || !detail || detail.pull.state !== 'open') { 875 + return; 876 + } 877 + 878 + const latestRoundIndex = detail.pull.value.rounds.length - 1; 879 + if (latestRoundIndex < 0) { 880 + setError('No pull request patch is available to merge'); 881 + return; 882 + } 883 + 884 + const confirmed = window.confirm( 885 + `Are you sure you want to merge pull #${detail.pull.number || detail.pull.rkey} into the ${detail.pull.value.target.branch} branch?`, 886 + ); 887 + if (!confirmed) return; 888 + 889 + setWorkingAction('merge'); 890 + setError(null); 891 + try { 892 + const patch = 893 + roundIndex() === latestRoundIndex && patchQuery.data 894 + ? patchQuery.data 895 + : await fetchPullRoundPatch(detail.pull, latestRoundIndex); 896 + await mergePull(agent, repo, { 897 + pull: detail.pull, 898 + patch, 899 + }); 900 + updatePullStatusCaches(repo, pullRef(), detail.pull.uri, 'merged'); 901 + await markPullQueriesStale(repo, pullRef(), detail.pull.uri); 902 + await client.invalidateQueries({ queryKey: ['repo-tree', repo.repoDid] }); 903 + await client.invalidateQueries({ queryKey: ['repo-log', repo.repoDid] }); 904 + await client.invalidateQueries({ queryKey: ['repo-branches', repo.repoDid] }); 905 + await client.invalidateQueries({ queryKey: ['repo-languages', repo.repoDid] }); 906 + } catch (cause) { 907 + setError(cause instanceof Error ? cause.message : 'Failed to merge pull request'); 908 + } finally { 909 + setWorkingAction(null); 807 910 } 808 911 }; 809 912 ··· 816 919 return; 817 920 } 818 921 819 - setWorking(true); 922 + setWorkingAction('comment'); 820 923 setError(null); 821 924 try { 822 925 await createPullComment(agent, detail.pull.uri, comment()); ··· 825 928 } catch (cause) { 826 929 setError(cause instanceof Error ? cause.message : 'Failed to create comment'); 827 930 } finally { 828 - setWorking(false); 931 + setWorkingAction(null); 829 932 } 830 933 }; 831 934 ··· 910 1013 </div> 911 1014 </Show> 912 1015 913 - <div class="mt-4 flex items-center gap-2"> 1016 + <div class="mt-4 flex flex-wrap items-center justify-end gap-2"> 914 1017 <Show when={auth.agent()}> 915 - <button type="button" class={clsx(buttonStyles(), 'ml-auto')} disabled={working()} onClick={() => void toggleState()}> 916 - {detail().pull.state === 'open' ? 'close pull' : 'reopen pull'} 917 - </button> 1018 + <Show when={detail().pull.state === 'open'}> 1019 + <button 1020 + type="button" 1021 + class={buttonStyles('primary')} 1022 + disabled={working() || detail().pull.value.rounds.length === 0} 1023 + onClick={() => void mergeCurrentPull()} 1024 + > 1025 + <Show when={workingAction() === 'merge'} fallback={<GitMerge class="size-4" />}> 1026 + <LoaderCircle class="size-4 animate-spin" /> 1027 + </Show> 1028 + merge 1029 + </button> 1030 + </Show> 1031 + <Show when={detail().pull.state !== 'merged'}> 1032 + <button type="button" class={buttonStyles()} disabled={working()} onClick={() => void toggleState()}> 1033 + <Show 1034 + when={workingAction() === 'state'} 1035 + fallback={ 1036 + <Show when={detail().pull.state === 'open'} fallback={<RefreshCcwDot class="size-4" />}> 1037 + <GitPullRequestClosed class="size-4" /> 1038 + </Show> 1039 + } 1040 + > 1041 + <LoaderCircle class="size-4 animate-spin" /> 1042 + </Show> 1043 + {detail().pull.state === 'open' ? 'close pull' : 'reopen pull'} 1044 + </button> 1045 + </Show> 918 1046 </Show> 919 1047 </div> 1048 + <Show when={error()}> 1049 + <p class="mt-3 text-sm text-red-500">{error()}</p> 1050 + </Show> 920 1051 </section> 921 1052 922 1053 <aside class="untangled-pr-meta untangled-thread-meta"> ··· 1010 1141 <Show when={error()}> 1011 1142 <p class="text-sm text-red-500">{error()}</p> 1012 1143 </Show> 1013 - <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working()}> 1014 - <MessageSquare class="size-4" /> 1144 + <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working() || !comment().trim()}> 1145 + <Show when={workingAction() === 'comment'} fallback={<MessageSquare class="size-4" />}> 1146 + <LoaderCircle class="size-4 animate-spin" /> 1147 + </Show> 1015 1148 comment 1016 1149 </button> 1017 1150 </form>