diff --git a/src/components/repo.tsx b/src/components/repo.tsx index 0470ac5..276893a 100644 --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -2,10 +2,12 @@ import clsx from 'clsx'; import DOMPurify from 'dompurify'; import hljs from 'highlight.js/lib/common'; import { + Check, ChevronRight, Circle, Columns2, Copy, + Download, ExternalLink, FileText, FoldVertical, @@ -14,14 +16,17 @@ import { PanelLeftOpen, PanelRightClose, PanelRightOpen, + Square, + SquareCheckBig, UnfoldVertical, } from 'lucide-solid'; import { marked } from 'marked'; import { A } from '@solidjs/router'; -import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; -import type { TreeEntry } from '../lib/api/repos'; +import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; +import type { RepoContext, TreeEntry } from '../lib/api/repos'; import { formatRelativeTime } from '../lib/repo-utils'; -import { Avatar, SkeletonBlock, cardStyles } from './common'; +import { getTangledAppviewService } from '../lib/settings'; +import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common'; marked.setOptions({ gfm: true, @@ -501,6 +506,166 @@ export const ReadmeCard: Component<{ filename?: string; markdown: string }> = (p ); +const cloneSshHost = (repo: RepoContext): string => { + try { + const host = new URL(repo.knot).hostname; + return host === 'knot1.tangled.sh' ? 'tangled.org' : host; + } catch { + const host = repo.knot.replace(/^https?:\/\//, '').split('/')[0].replace(/:\d+$/, ''); + return host === 'knot1.tangled.sh' ? 'tangled.org' : host; + } +}; + +const appviewArchiveUrl = (repo: RepoContext, refName: string): string => { + const owner = encodeURIComponent(repo.owner.handle); + const slug = encodeURIComponent(repo.slug); + const ref = encodeURIComponent(refName); + return new URL(`/${owner}/${slug}/archive/${ref}`, getTangledAppviewService()).toString(); +}; + +const selectContents = (element: HTMLElement) => { + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); +}; + +const CloneUrlItem: Component<{ + label: string; + handleUrl: string; + permaUrl: string; + permalink: boolean; +}> = (props) => { + const [copied, setCopied] = createSignal(false); + const visibleUrl = createMemo(() => (props.permalink ? props.permaUrl : props.handleUrl)); + let copyTimer: ReturnType | undefined; + + onCleanup(() => clearTimeout(copyTimer)); + + const copy = () => { + clearTimeout(copyTimer); + void navigator.clipboard?.writeText(visibleUrl()); + setCopied(true); + copyTimer = setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ +
+ selectContents(event.currentTarget)} + title={visibleUrl()} + > + {visibleUrl()} + + +
+
+ ); +}; + +export const CloneDropdown: Component<{ repo: RepoContext; refName: string }> = (props) => { + const [open, setOpen] = createSignal(false); + const [permalink, setPermalink] = createSignal(false); + const sshHost = createMemo(() => cloneSshHost(props.repo)); + let root: HTMLDivElement | undefined; + + onMount(() => { + const closeOnOutsideClick = (event: MouseEvent) => { + if (!open() || !root || root.contains(event.target as Node)) return; + setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + + document.addEventListener('mousedown', closeOnOutsideClick); + document.addEventListener('keydown', closeOnEscape); + onCleanup(() => { + document.removeEventListener('mousedown', closeOnOutsideClick); + document.removeEventListener('keydown', closeOnEscape); + }); + }); + + return ( +
+ + + + +
+ ); +}; + export const CommentCard: Component<{ author: string; did: string; diff --git a/src/index.css b/src/index.css index 9b58721..79d35e9 100644 --- a/src/index.css +++ b/src/index.css @@ -1040,6 +1040,65 @@ details[open] > .untangled-language-bar { min-height: 30px; } +.untangled-clone-dropdown { + position: relative; + display: inline-flex; +} + +.untangled-clone-popover { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 70; + width: min(calc(100vw - 2rem), 27.5rem); + overflow: visible; + border: 1px solid rgb(229 231 235); + border-radius: 0.25rem; + background: rgb(255 255 255); + padding: 1rem; + color: rgb(17 24 39); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +.untangled-clone-url-row { + display: flex; + align-items: stretch; + overflow: hidden; + border: 1px solid rgb(209 213 219); + border-radius: 0.25rem; +} + +.untangled-clone-url-text { + min-width: 0; + flex: 1; + overflow-x: auto; + background: rgb(229 231 235); + padding: 0.5rem 0.75rem; + color: rgb(17 24 39); + font-family: IBMPlexMono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; + font-size: 0.875rem; + white-space: nowrap; + cursor: pointer; + user-select: all; +} + +.untangled-clone-copy { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-left: 1px solid rgb(209 213 219); + background: rgb(255 255 255); + padding: 0 0.75rem; + color: rgb(107 114 128); +} + +.untangled-clone-copy:hover { + color: rgb(55 65 81); +} + .untangled-input { min-height: 38px; } @@ -2059,6 +2118,31 @@ details[open] > .untangled-language-bar { background: rgb(55 65 81 / 0.4); } + .untangled-clone-popover { + border-color: rgb(55 65 81); + background: rgb(31 41 55); + color: rgb(243 244 246); + } + + .untangled-clone-url-row { + border-color: rgb(75 85 99); + } + + .untangled-clone-url-text { + background: rgb(55 65 81); + color: rgb(243 244 246); + } + + .untangled-clone-copy { + border-left-color: rgb(75 85 99); + background: rgb(31 41 55); + color: rgb(156 163 175); + } + + .untangled-clone-copy:hover { + color: rgb(229 231 235); + } + .untangled-blob-card { background: rgb(31 41 55); } diff --git a/src/lib/api.ts b/src/lib/api.ts index ebf7b81..91577a6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -58,6 +58,7 @@ export { getRepoPullCount, listPulls, listPullsPage, + mergePull, setPullStatus, } from './api/pulls'; export { @@ -105,6 +106,7 @@ export type { } from './api/issues'; export type { CreatePullInput, + MergePullInput, PullComment, PullDetail, PullSummary, diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index bc306f6..5d9d24f 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,4 +1,5 @@ -import { Client, simpleFetchHandler } from '@atcute/client'; +import { Client, ok, simpleFetchHandler } from '@atcute/client'; +import type { Nsid } from '@atcute/lexicons/syntax'; import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; const rpcCache = new Map(); @@ -32,3 +33,37 @@ export const createAuthRpc = (agent: OAuthUserAgent): Client => new Client({ handler: agent, }); + +const serviceAudience = (service: string): `did:web:${string}` => { + const url = new URL(normalizeServiceUrl(service)); + return `did:web:${url.host}`; +}; + +export const createServiceAuthRpc = async ( + agent: OAuthUserAgent, + service: string, + lxm: Nsid, +): Promise => { + const authRpc = createAuthRpc(agent); + const { token } = await ok( + authRpc.get('com.atproto.server.getServiceAuth', { + params: { + aud: serviceAudience(service), + exp: Math.floor(Date.now() / 1000) + 60, + lxm, + }, + }), + ); + const serviceUrl = normalizeServiceUrl(service); + + return new Client({ + handler: async (pathname, init) => { + const headers = new Headers(init.headers); + headers.set('authorization', `Bearer ${token}`); + return fetch(new URL(pathname, serviceUrl), { + ...init, + headers, + }); + }, + }); +}; diff --git a/src/lib/api/pulls.ts b/src/lib/api/pulls.ts index 6b8c71c..4c76a87 100644 --- a/src/lib/api/pulls.ts +++ b/src/lib/api/pulls.ts @@ -2,6 +2,7 @@ import { ok } from '@atcute/client'; import type { ResourceUri } from '@atcute/lexicons/syntax'; import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; import { + ShTangledRepoMerge, ShTangledRepoPull, ShTangledRepoPullComment, ShTangledRepoPullStatus, @@ -28,7 +29,7 @@ import { PULL_COMMENT_COLLECTION, PULL_STATUS_COLLECTION, } from './constants'; -import { getRpc } from './client'; +import { createServiceAuthRpc, getRpc } from './client'; import { appviewRecordToHydrated, fetchRecordValue, @@ -84,6 +85,11 @@ export interface CreatePullInput { patch: string; } +export interface MergePullInput { + pull: PullSummary; + patch: string; +} + const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { if (value === 'merged' || value?.endsWith('.merged')) { return 'merged'; @@ -447,3 +453,30 @@ export const setPullStatus = async ( rkey: parseAtUri(result.uri).rkey, }; }; + +export const mergePull = async ( + agent: OAuthUserAgent, + repo: RepoContext, + input: MergePullInput, +): Promise => { + const rpc = await createServiceAuthRpc(agent, repo.knot, 'sh.tangled.repo.merge'); + const pull = input.pull; + const mergeInput: ShTangledRepoMerge.$input = { + did: repo.owner.did, + name: repo.slug, + branch: pull.value.target.branch, + patch: input.patch, + commitMessage: pull.value.title, + authorName: pull.author.handle, + ...(pull.value.body ? { commitBody: pull.value.body } : {}), + }; + + await ok( + rpc.post('sh.tangled.repo.merge', { + input: mergeInput, + as: null, + }), + ); + + return setPullStatus(agent, pull.uri, 'merged'); +}; diff --git a/src/pages/repo/code.tsx b/src/pages/repo/code.tsx index 8056b5d..8be28ae 100644 --- a/src/pages/repo/code.tsx +++ b/src/pages/repo/code.tsx @@ -1,11 +1,11 @@ import clsx from 'clsx'; -import { Download, File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid'; +import { File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid'; import { A, useNavigate, useParams } from '@solidjs/router'; import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; import { buildBlobDataUrl, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree } from '../../lib/api/repos'; import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; -import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo'; +import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo'; import { RepoFrame, useRepoQuery } from './shared'; 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'; import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers'; @@ -636,10 +636,7 @@ export const RepoCodePage: Component = () => { - - - code - + diff --git a/src/pages/repo/issues.tsx b/src/pages/repo/issues.tsx index d796c37..14c99c6 100644 --- a/src/pages/repo/issues.tsx +++ b/src/pages/repo/issues.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, Reply, Search, Smile, X } from 'lucide-solid'; +import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, RefreshCcwDot, Reply, Search, Smile, X } from 'lucide-solid'; import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; import { createQuery, useQueryClient } from '@tanstack/solid-query'; import type { ResourceUri } from '@atcute/lexicons/syntax'; @@ -264,6 +264,39 @@ export const NewIssuePage: Component = () => { ); }; +const updateIssueStateData = ( + data: unknown, + issueUri: ResourceUri, + state: 'open' | 'closed', +): unknown => { + if (!data || typeof data !== 'object') return data; + + if ('issue' in data) { + const detail = data as { issue?: { uri?: string; state?: string } }; + if (detail.issue?.uri === issueUri) { + return { + ...detail, + issue: { + ...detail.issue, + state, + }, + }; + } + } + + if ('items' in data) { + const page = data as { items?: Array<{ uri?: string; state?: string }> }; + if (Array.isArray(page.items)) { + return { + ...page, + items: page.items.map((issue) => (issue.uri === issueUri ? { ...issue, state } : issue)), + }; + } + } + + return data; +}; + export const IssuePage: Component = () => { const auth = useAuth(); const repoQuery = useRepoQuery(); @@ -273,7 +306,8 @@ export const IssuePage: Component = () => { const [comment, setComment] = createSignal(''); const [replyingTo, setReplyingTo] = createSignal(null); const [replyBody, setReplyBody] = createSignal(''); - const [working, setWorking] = createSignal(false); + const [workingAction, setWorkingAction] = createSignal<'comment' | 'reply' | 'state' | null>(null); + const working = createMemo(() => workingAction() !== null); const [error, setError] = createSignal(null); const issueQuery = createQuery(() => { @@ -291,6 +325,23 @@ export const IssuePage: Component = () => { await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(issueUri).rkey) }); }; + const updateIssueStateCaches = ( + repo: RepoContext, + currentIssueRef: string, + issueUri: ResourceUri, + state: 'open' | 'closed', + ) => { + client.setQueryData(issueQueryKey(repo.repoDid, currentIssueRef), (data: unknown) => + updateIssueStateData(data, issueUri, state), + ); + client.setQueryData(issueQueryKey(repo.repoDid, parseAtUri(issueUri).rkey), (data: unknown) => + updateIssueStateData(data, issueUri, state), + ); + client.setQueriesData({ queryKey: issuesQueryKey(repo.repoDid) }, (data: unknown) => + updateIssueStateData(data, issueUri, state), + ); + }; + const toggleState = async () => { const agent = auth.agent(); const repo = repoQuery.data; @@ -299,15 +350,29 @@ export const IssuePage: Component = () => { return; } - setWorking(true); + const nextState = detail.issue.state === 'open' ? 'closed' : 'open'; + const commentBody = nextState === 'closed' ? comment().trim() : ''; + + setWorkingAction('state'); setError(null); try { - await setIssueState(agent, detail.issue.uri, detail.issue.state === 'open' ? 'closed' : 'open'); - await withIssueInvalidation(repo, issueRef(), detail.issue.uri); + if (commentBody) { + await createIssueComment(agent, detail.issue.uri, commentBody); + setComment(''); + } + await setIssueState(agent, detail.issue.uri, nextState); + if (commentBody) { + await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef()) }); + await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(detail.issue.uri).rkey) }); + } + updateIssueStateCaches(repo, issueRef(), detail.issue.uri, nextState); + await client.invalidateQueries({ queryKey: issuesQueryKey(repo.repoDid), refetchType: 'none' }); + await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef()), refetchType: 'none' }); + await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(detail.issue.uri).rkey), refetchType: 'none' }); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to update issue state'); } finally { - setWorking(false); + setWorkingAction(null); } }; @@ -320,7 +385,7 @@ export const IssuePage: Component = () => { return; } - setWorking(true); + setWorkingAction('comment'); setError(null); try { await createIssueComment(agent, detail.issue.uri, comment()); @@ -329,7 +394,7 @@ export const IssuePage: Component = () => { } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to create comment'); } finally { - setWorking(false); + setWorkingAction(null); } }; @@ -342,7 +407,7 @@ export const IssuePage: Component = () => { return; } - setWorking(true); + setWorkingAction('reply'); setError(null); try { await createIssueComment(agent, detail.issue.uri, replyBody(), replyTo); @@ -352,7 +417,7 @@ export const IssuePage: Component = () => { } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to create reply'); } finally { - setWorking(false); + setWorkingAction(null); } }; @@ -516,7 +581,9 @@ export const IssuePage: Component = () => { cancel @@ -551,12 +618,23 @@ export const IssuePage: Component = () => {
diff --git a/src/pages/repo/pulls.tsx b/src/pages/repo/pulls.tsx index b5982f2..cae8e42 100644 --- a/src/pages/repo/pulls.tsx +++ b/src/pages/repo/pulls.tsx @@ -11,6 +11,7 @@ import { LoaderCircle, MessageSquare, Pencil, + RefreshCcwDot, Search, X, } from 'lucide-solid'; @@ -18,7 +19,7 @@ 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, setPullStatus } from '../../lib/api/pulls'; +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 { useAuth } from '../../lib/auth'; @@ -745,6 +746,39 @@ const PullSourceTab: Component<{ ); +const updatePullStatusData = ( + data: unknown, + pullUri: string, + state: 'open' | 'closed' | 'merged', +): unknown => { + if (!data || typeof data !== 'object') return data; + + if ('pull' in data) { + const detail = data as { pull?: { uri?: string; state?: string } }; + if (detail.pull?.uri === pullUri) { + return { + ...detail, + pull: { + ...detail.pull, + state, + }, + }; + } + } + + if ('items' in data) { + const page = data as { items?: Array<{ uri?: string; state?: string }> }; + if (Array.isArray(page.items)) { + return { + ...page, + items: page.items.map((pull) => (pull.uri === pullUri ? { ...pull, state } : pull)), + }; + } + } + + return data; +}; + export const PullPage: Component = () => { const auth = useAuth(); const repoQuery = useRepoQuery(); @@ -753,7 +787,8 @@ export const PullPage: Component = () => { const pullRef = createMemo(() => params.pullRef ?? ''); const [roundIndex, setRoundIndex] = createSignal(0); const [comment, setComment] = createSignal(''); - const [working, setWorking] = createSignal(false); + const [workingAction, setWorkingAction] = createSignal<'comment' | 'state' | 'merge' | null>(null); + const working = createMemo(() => workingAction() !== null); const [error, setError] = createSignal(null); const pullQuery = createQuery(() => { @@ -787,6 +822,29 @@ export const PullPage: Component = () => { await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey) }); }; + const updatePullStatusCaches = ( + repo: RepoContext, + currentPullRef: string, + pullUri: string, + state: 'open' | 'closed' | 'merged', + ) => { + client.setQueryData(pullQueryKey(repo.repoDid, currentPullRef), (data: unknown) => + updatePullStatusData(data, pullUri, state), + ); + client.setQueryData(pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey), (data: unknown) => + updatePullStatusData(data, pullUri, state), + ); + client.setQueriesData({ queryKey: pullsQueryKey(repo.repoDid) }, (data: unknown) => + updatePullStatusData(data, pullUri, state), + ); + }; + + const markPullQueriesStale = async (repo: RepoContext, currentPullRef: string, pullUri: string) => { + await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid), refetchType: 'none' }); + await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, currentPullRef), refetchType: 'none' }); + await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey), refetchType: 'none' }); + }; + const toggleState = async () => { const agent = auth.agent(); const repo = repoQuery.data; @@ -795,15 +853,60 @@ export const PullPage: Component = () => { return; } - setWorking(true); + const nextState = detail.pull.state === 'open' ? 'closed' : 'open'; + setWorkingAction('state'); setError(null); try { - await setPullStatus(agent, detail.pull.uri, detail.pull.state === 'open' ? 'closed' : 'open'); - await invalidate(repo, pullRef(), detail.pull.uri); + await setPullStatus(agent, detail.pull.uri, nextState); + updatePullStatusCaches(repo, pullRef(), detail.pull.uri, nextState); + await markPullQueriesStale(repo, pullRef(), detail.pull.uri); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to update pull state'); } finally { - setWorking(false); + setWorkingAction(null); + } + }; + + const mergeCurrentPull = async () => { + const agent = auth.agent(); + const repo = repoQuery.data; + const detail = pullQuery.data; + if (!agent || !repo || !detail || detail.pull.state !== 'open') { + return; + } + + const latestRoundIndex = detail.pull.value.rounds.length - 1; + if (latestRoundIndex < 0) { + setError('No pull request patch is available to merge'); + return; + } + + const confirmed = window.confirm( + `Are you sure you want to merge pull #${detail.pull.number || detail.pull.rkey} into the ${detail.pull.value.target.branch} branch?`, + ); + if (!confirmed) return; + + setWorkingAction('merge'); + setError(null); + try { + const patch = + roundIndex() === latestRoundIndex && patchQuery.data + ? patchQuery.data + : await fetchPullRoundPatch(detail.pull, latestRoundIndex); + await mergePull(agent, repo, { + pull: detail.pull, + patch, + }); + updatePullStatusCaches(repo, pullRef(), detail.pull.uri, 'merged'); + await markPullQueriesStale(repo, pullRef(), detail.pull.uri); + await client.invalidateQueries({ queryKey: ['repo-tree', repo.repoDid] }); + await client.invalidateQueries({ queryKey: ['repo-log', repo.repoDid] }); + await client.invalidateQueries({ queryKey: ['repo-branches', repo.repoDid] }); + await client.invalidateQueries({ queryKey: ['repo-languages', repo.repoDid] }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'Failed to merge pull request'); + } finally { + setWorkingAction(null); } }; @@ -816,7 +919,7 @@ export const PullPage: Component = () => { return; } - setWorking(true); + setWorkingAction('comment'); setError(null); try { await createPullComment(agent, detail.pull.uri, comment()); @@ -825,7 +928,7 @@ export const PullPage: Component = () => { } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to create comment'); } finally { - setWorking(false); + setWorkingAction(null); } }; @@ -910,13 +1013,41 @@ export const PullPage: Component = () => {
-
+
- + + + + + +
+ +

{error()}

+