csr tangled client in solid-js
15

Configure Feed

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

use search in repo wide issue / pull search

dawn (May 21, 2026, 11:12 AM +0300) d45b8829 e0a03f17

+390 -84
+2
src/lib/api.ts
··· 43 43 createIssueComment, 44 44 getIssue, 45 45 getIssueRecord, 46 + getRepoIssueCount, 46 47 listIssues, 47 48 listIssuesPage, 48 49 setIssueState, ··· 53 54 fetchPullRoundPatch, 54 55 getPull, 55 56 getPullRecord, 57 + getRepoPullCount, 56 58 listPulls, 57 59 listPullsPage, 58 60 setPullStatus,
+308 -20
src/lib/api/core.ts
··· 398 398 cursor?: string | null; 399 399 } 400 400 401 + interface AppviewBulkResponse<T> { 402 + items: Array<AppviewRecordView<T>>; 403 + } 404 + 401 405 interface AppviewStatefulRecordView<T> extends AppviewRecordView<T> { 402 406 state?: string; 403 407 stateUpdatedAt?: string; ··· 440 444 } 441 445 442 446 const APPVIEW_PAGE_BATCH_LIMIT = 100; 447 + const SEARCH_PAGE_BATCH_LIMIT = 100; 443 448 const APPVIEW_CURSOR_CACHE_LIMIT = 64; 444 449 const appviewPageCursorCache = new Map<string, Map<number, AppviewCursorCheckpoint>>(); 445 450 ··· 476 481 477 482 const maxTimestamp = (...values: number[]): number => 478 483 values.reduce((latest, value) => Math.max(latest, value), 0); 484 + 485 + const getAppviewRecordSortTimestamp = <T extends { createdAt?: string }>( 486 + record: HydratedRecord<T>, 487 + ): number => 488 + maxTimestamp( 489 + timestampFromDate(record.value.createdAt), 490 + timestampFromTid(record.rkey), 491 + ); 492 + 493 + const sortStatefulRecordsByNewestRecord = <T extends { createdAt?: string }, State extends string>( 494 + items: Array<StatefulHydratedRecord<T, State>>, 495 + ): Array<StatefulHydratedRecord<T, State>> => 496 + [...items].sort((left, right) => { 497 + const timestampDiff = getAppviewRecordSortTimestamp(right) - getAppviewRecordSortTimestamp(left); 498 + if (timestampDiff !== 0) { 499 + return timestampDiff; 500 + } 501 + 502 + if (left.number !== right.number) { 503 + return right.number - left.number; 504 + } 505 + 506 + return right.rkey.localeCompare(left.rkey); 507 + }); 479 508 480 509 const sortStatefulRecordsByUpdatedAt = async <T, State extends string>( 481 510 items: Array<StatefulHydratedRecord<T, State>>, ··· 642 671 return (await response.json()) as T; 643 672 }; 644 673 674 + const appviewJsonUrl = async <T>(url: URL, signal?: AbortSignal): Promise<T> => { 675 + let response: Response; 676 + try { 677 + response = await fetch(url, { 678 + headers: { 679 + accept: 'application/json', 680 + }, 681 + signal, 682 + }); 683 + } catch (cause) { 684 + throw new AppviewUnavailableError(cause); 685 + } 686 + 687 + if (!response.ok) { 688 + let message = `${response.status} ${response.statusText}`.trim(); 689 + try { 690 + const body = (await response.json()) as { message?: unknown; error?: unknown }; 691 + const bodyMessage = typeof body.message === 'string' ? body.message : body.error; 692 + if (typeof bodyMessage === 'string') { 693 + message = bodyMessage; 694 + } 695 + } catch { 696 + // Keep the HTTP status message when the appview does not return JSON. 697 + } 698 + 699 + throw new AppviewResponseError(response.status, `Appview ${url.pathname.slice('/xrpc/'.length)} failed (${response.status}): ${message}`); 700 + } 701 + 702 + return (await response.json()) as T; 703 + }; 704 + 645 705 const appviewFallback = async <T>(primary: () => Promise<T>, fallback: () => Promise<T>): Promise<T> => { 646 706 try { 647 707 return await primary(); ··· 676 736 return records; 677 737 }; 678 738 739 + const appviewBulkRecords = async <T>( 740 + nsid: string, 741 + paramName: string, 742 + uris: ResourceUri[], 743 + ): Promise<Array<AppviewRecordView<T>>> => { 744 + if (uris.length === 0) { 745 + return []; 746 + } 747 + 748 + const records: Array<AppviewRecordView<T>> = []; 749 + for (let index = 0; index < uris.length; index += 50) { 750 + const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); 751 + for (const uri of uris.slice(index, index + 50)) { 752 + url.searchParams.append(paramName, uri); 753 + } 754 + 755 + const page = await appviewJsonUrl<AppviewBulkResponse<T>>(url); 756 + records.push(...page.items); 757 + } 758 + 759 + return records; 760 + }; 761 + 679 762 const appviewRecordToHydrated = async <T>(record: AppviewRecordView<T>): Promise<HydratedRecord<T>> => { 680 763 const parsed = parseAtUri(record.uri); 681 764 const author = await resolveActorForDisplay(parsed.did); ··· 914 997 } 915 998 }; 916 999 1000 + const listSearchedStatefulRecordsPage = async <T, State extends string>( 1001 + nsid: SearchCollection, 1002 + repo: RepoContext, 1003 + options: StatefulPageOptions<State>, 1004 + getState: (uri: ResourceUri) => Promise<State>, 1005 + ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 1006 + const query = options.query?.trim(); 1007 + if (!query) { 1008 + return { items: [], totalCount: 0, hasNext: false }; 1009 + } 1010 + 1011 + const offset = Math.max(options.offset, 0); 1012 + const limit = Math.max(options.limit, 1); 1013 + const requestedEnd = offset + limit; 1014 + const items: Array<StatefulHydratedRecord<T, State>> = []; 1015 + let cursor: string | undefined; 1016 + let matchingSeen = 0; 1017 + 1018 + while (true) { 1019 + const page = await appviewJson<AppviewSearchResponse<T>>('sh.tangled.search.query', { 1020 + q: query, 1021 + nsid, 1022 + author: options.author, 1023 + repo: repo.repoDid, 1024 + cursor, 1025 + limit: SEARCH_PAGE_BATCH_LIMIT, 1026 + }); 1027 + const hydrated = await Promise.all(page.hits.map((hit) => appviewRecordToHydrated(hit))); 1028 + const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 1029 + 1030 + for (const [index, record] of hydrated.entries()) { 1031 + const state = states[index]; 1032 + if (state !== options.state) { 1033 + continue; 1034 + } 1035 + 1036 + if (matchingSeen >= offset && matchingSeen < requestedEnd) { 1037 + items.push({ 1038 + ...record, 1039 + number: 0, 1040 + state, 1041 + }); 1042 + } 1043 + 1044 + matchingSeen += 1; 1045 + if (matchingSeen > requestedEnd) { 1046 + return { 1047 + items: items.slice(0, limit), 1048 + hasNext: true, 1049 + }; 1050 + } 1051 + } 1052 + 1053 + cursor = page.cursor ?? undefined; 1054 + if (!cursor || page.hits.length === 0) { 1055 + return { 1056 + items, 1057 + totalCount: matchingSeen, 1058 + hasNext: false, 1059 + }; 1060 + } 1061 + } 1062 + }; 1063 + 917 1064 const getRepoViaAppview = async <T>( 918 1065 nsid: string, 919 1066 repo: RepoContext, ··· 1057 1204 subject: repo.repoDid, 1058 1205 }); 1059 1206 return count.distinctAuthors ?? count.distinct_authors ?? count.count; 1207 + }; 1208 + 1209 + const getAppviewRecordCount = async (nsid: string, subject: string): Promise<number> => { 1210 + const count = await appviewJson<AppviewCountResponse>(nsid, { subject }); 1211 + return count.count; 1060 1212 }; 1061 1213 1062 1214 const findCurrentUserStarFromAppview = async ( ··· 1567 1719 const hydrateBacklinks = async <T>(refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<T>>> => 1568 1720 Promise.all(refs.map((ref) => hydrateRecord<T>(ref))); 1569 1721 1722 + const backlinkRefToUri = (ref: BacklinkRecordRef): ResourceUri => 1723 + `at://${ref.did}/${ref.collection}/${ref.rkey}` as ResourceUri; 1724 + 1725 + const hydrateBacklinksFromAppview = async <T>( 1726 + refs: BacklinkRecordRef[], 1727 + nsid: string, 1728 + paramName: string, 1729 + ): Promise<Array<HydratedRecord<T>>> => 1730 + appviewFallback( 1731 + async () => { 1732 + const records = await appviewBulkRecords<T>( 1733 + nsid, 1734 + paramName, 1735 + refs.map(backlinkRefToUri), 1736 + ); 1737 + return Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1738 + }, 1739 + () => hydrateBacklinks<T>(refs), 1740 + ); 1741 + 1742 + const hydrateIssueBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoIssue.Main>>> => 1743 + hydrateBacklinksFromAppview<ShTangledRepoIssue.Main>( 1744 + refs, 1745 + 'sh.tangled.repo.getIssues', 1746 + 'issues', 1747 + ); 1748 + 1749 + const hydratePullBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoPull.Main>>> => 1750 + hydrateBacklinksFromAppview<ShTangledRepoPull.Main>( 1751 + refs, 1752 + 'sh.tangled.repo.getPulls', 1753 + 'pulls', 1754 + ); 1755 + 1570 1756 const getOptionalRecord = async <T>( 1571 1757 actor: ResolvedActor, 1572 1758 collection: Nsid, ··· 1665 1851 options: StatefulPageOptions<State>, 1666 1852 getState: (uri: ResourceUri) => Promise<State>, 1667 1853 getUpdatedAt?: (record: StatefulHydratedRecord<T, State>) => Promise<number>, 1854 + hydrateRefs: (refs: BacklinkRecordRef[]) => Promise<Array<HydratedRecord<T>>> = hydrateBacklinks<T>, 1668 1855 ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 1669 1856 if (getUpdatedAt) { 1670 - const hydrated = await hydrateBacklinks<T>(await getUniqueBacklinkRefs(subject, source)); 1857 + const hydrated = await hydrateRefs(await getUniqueBacklinkRefs(subject, source)); 1671 1858 const numbers = toNumberMap(hydrated); 1672 1859 const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 1673 1860 const items = hydrated.map((record, index) => ({ ··· 1734 1921 return { items, totalCount: matchingSeen, hasNext: false }; 1735 1922 } 1736 1923 1737 - const hydrated = await hydrateBacklinks<T>(refs); 1924 + const hydrated = await hydrateRefs(refs); 1738 1925 hydrated.sort(sortByCreatedAt).reverse(); 1739 1926 const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 1740 1927 ··· 1824 2011 'sh.tangled.repo.issue:repoDid', 1825 2012 'sh.tangled.repo.issue:repo', 1826 2013 ]); 1827 - const issues = await hydrateBacklinks<ShTangledRepoIssue.Main>(refs); 2014 + const issues = await hydrateIssueBacklinks(refs); 1828 2015 const numbers = toNumberMap(issues); 1829 2016 const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri))); 1830 2017 ··· 1848 2035 options, 1849 2036 getLatestIssueState, 1850 2037 (issue) => getIssueUpdatedAt(issue), 2038 + hydrateIssueBacklinks, 1851 2039 ); 1852 2040 2041 + const getIssueCountFromBacklinks = async (repo: RepoContext): Promise<number> => 2042 + (await getUniqueBacklinkRefs(repo.repoDid, [ 2043 + 'sh.tangled.repo.issue:repoDid', 2044 + 'sh.tangled.repo.issue:repo', 2045 + ])).length; 2046 + 1853 2047 const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 1854 2048 const issues = await listIssuesFromBacklinks(repo); 1855 2049 const issue = findNumberedRecord(issues, issueRef); ··· 1869 2063 1870 2064 const listPullsFromBacklinks = async (repo: RepoContext): Promise<PullSummary[]> => { 1871 2065 const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); 1872 - const pulls = await hydrateBacklinks<ShTangledRepoPull.Main>(refs); 2066 + const pulls = await hydratePullBacklinks(refs); 1873 2067 const numbers = toNumberMap(pulls); 1874 2068 const states = await Promise.all(pulls.map((pull) => getLatestPullStatus(pull.uri))); 1875 2069 ··· 1893 2087 options, 1894 2088 getLatestPullStatus, 1895 2089 (pull) => getPullUpdatedAt(pull), 2090 + hydratePullBacklinks, 1896 2091 ); 1897 2092 2093 + const getPullCountFromBacklinks = async (repo: RepoContext): Promise<number> => 2094 + (await getBacklinksPage(repo.repoDid, 'sh.tangled.repo.pull:target.repo', 1)).total ?? 2095 + (await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo')).length; 2096 + 1898 2097 const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 1899 2098 const pulls = await listPullsFromBacklinks(repo); 1900 2099 const pull = findNumberedRecord(pulls, pullRef); ··· 1939 2138 ) as Array<AppviewStatefulRecordView<ShTangledRepoIssue.Main>>; 1940 2139 const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1941 2140 const numbers = toNumberMap(issues); 1942 - const stateUpdatedAtByUri = new Map(records.map((record) => [record.uri, record.stateUpdatedAt])); 1943 2141 1944 2142 const summaries = issues 1945 2143 .map((issue, index) => ({ ··· 1948 2146 state: normalizeIssueState(records[index].state), 1949 2147 })); 1950 2148 1951 - return sortStatefulRecordsByUpdatedAt( 1952 - summaries, 1953 - (issue) => getIssueUpdatedAt(issue, stateUpdatedAtByUri.get(issue.uri)), 1954 - ); 2149 + return sortStatefulRecordsByNewestRecord(summaries); 1955 2150 }; 1956 2151 1957 2152 const listIssuesPageFromAppview = async ( 1958 2153 repo: RepoContext, 1959 2154 options: StatefulPageOptions<'open' | 'closed'>, 1960 - ): Promise<PaginatedResult<IssueSummary>> => 1961 - listAppviewStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 2155 + ): Promise<PaginatedResult<IssueSummary>> => { 2156 + if (options.query?.trim()) { 2157 + // Appview search can scope to repo/author/nsid. State is still derived 2158 + // from separate state records, so filter state after each search hit. 2159 + return listSearchedStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 2160 + 'sh.tangled.repo.issue', 2161 + repo, 2162 + options, 2163 + getLatestIssueState, 2164 + ); 2165 + } 2166 + 2167 + // Temporary until Bobbin exposes newest-first/reverse repo issue pagination. 2168 + // This loads all appview issue records, but sorts without per-item backlinks. 2169 + return listAppviewStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 1962 2170 'sh.tangled.repo.listIssues', 1963 2171 repo.repoDid, 1964 2172 options, 1965 2173 normalizeIssueState, 1966 2174 options.author ? { author: options.author } : {}, 1967 - (issue, record) => getIssueUpdatedAt(issue, record.stateUpdatedAt), 2175 + (issue) => Promise.resolve(getAppviewRecordSortTimestamp(issue)), 1968 2176 ); 2177 + }; 1969 2178 1970 2179 const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 2180 + const directUri = parseDirectRecordRef(issueRef, ISSUE_COLLECTION); 2181 + if (directUri) { 2182 + const issueRecord = await getIssueRecord(directUri); 2183 + if (issueRecord.value.repo !== repo.repoDid) { 2184 + throw new Error(`Issue not found`); 2185 + } 2186 + 2187 + const issue: IssueSummary = { 2188 + ...issueRecord, 2189 + number: 0, 2190 + state: await getLatestIssueState(issueRecord.uri), 2191 + }; 2192 + 2193 + return { 2194 + issue, 2195 + comments: await listIssueCommentsFromAppview(issue.uri), 2196 + }; 2197 + } 2198 + 1971 2199 const issues = await listIssuesFromAppview(repo); 1972 2200 const issue = findNumberedRecord(issues, issueRef); 1973 2201 if (!issue) { ··· 1987 2215 ) as Array<AppviewStatefulRecordView<ShTangledRepoPull.Main>>; 1988 2216 const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1989 2217 const numbers = toNumberMap(pulls); 1990 - const stateUpdatedAtByUri = new Map(records.map((record) => [record.uri, record.stateUpdatedAt])); 1991 2218 1992 2219 const summaries = pulls 1993 2220 .map((pull, index) => ({ ··· 1996 2223 state: normalizePullStatus(records[index].state), 1997 2224 })); 1998 2225 1999 - return sortStatefulRecordsByUpdatedAt( 2000 - summaries, 2001 - (pull) => getPullUpdatedAt(pull, stateUpdatedAtByUri.get(pull.uri)), 2002 - ); 2226 + return sortStatefulRecordsByNewestRecord(summaries); 2003 2227 }; 2004 2228 2005 2229 const listPullsPageFromAppview = async ( 2006 2230 repo: RepoContext, 2007 2231 options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 2008 - ): Promise<PaginatedResult<PullSummary>> => 2009 - listAppviewStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 2232 + ): Promise<PaginatedResult<PullSummary>> => { 2233 + if (options.query?.trim()) { 2234 + // Appview search can scope to repo/author/nsid. State is still derived 2235 + // from separate status records, so filter state after each search hit. 2236 + return listSearchedStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 2237 + 'sh.tangled.repo.pull', 2238 + repo, 2239 + options, 2240 + getLatestPullStatus, 2241 + ); 2242 + } 2243 + 2244 + // Temporary until Bobbin exposes newest-first/reverse repo pull pagination. 2245 + // This loads all appview pull records, but sorts without per-item backlinks. 2246 + return listAppviewStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 2010 2247 'sh.tangled.repo.listPulls', 2011 2248 repo.repoDid, 2012 2249 options, 2013 2250 normalizePullStatus, 2014 2251 options.author ? { author: options.author } : {}, 2015 - (pull, record) => getPullUpdatedAt(pull, record.stateUpdatedAt), 2252 + (pull) => Promise.resolve(getAppviewRecordSortTimestamp(pull)), 2016 2253 ); 2254 + }; 2017 2255 2018 2256 const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 2257 + const directUri = parseDirectRecordRef(pullRef, PULL_COLLECTION); 2258 + if (directUri) { 2259 + const pullRecord = await getPullRecord(directUri); 2260 + if (pullRecord.value.target.repo !== repo.repoDid) { 2261 + throw new Error(`Pull request not found`); 2262 + } 2263 + 2264 + const pull: PullSummary = { 2265 + ...pullRecord, 2266 + number: 0, 2267 + state: await getLatestPullStatus(pullRecord.uri), 2268 + }; 2269 + 2270 + return { 2271 + pull, 2272 + comments: await listPullCommentsFromAppview(pull.uri), 2273 + }; 2274 + } 2275 + 2019 2276 const pulls = await listPullsFromAppview(repo); 2020 2277 const pull = findNumberedRecord(pulls, pullRef); 2021 2278 if (!pull) { ··· 2043 2300 () => listIssuesPageFromBacklinks(repo, options), 2044 2301 ); 2045 2302 2303 + export const getRepoIssueCount = async (repo: RepoContext): Promise<number> => 2304 + appviewFallback( 2305 + () => getAppviewRecordCount('sh.tangled.repo.countIssues', repo.repoDid), 2306 + () => getIssueCountFromBacklinks(repo), 2307 + ); 2308 + 2046 2309 export const getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => 2047 2310 appviewFallback( 2048 2311 () => getIssueFromAppview(repo, issueRef), ··· 2062 2325 appviewFallback( 2063 2326 () => listPullsPageFromAppview(repo, options), 2064 2327 () => listPullsPageFromBacklinks(repo, options), 2328 + ); 2329 + 2330 + export const getRepoPullCount = async (repo: RepoContext): Promise<number> => 2331 + appviewFallback( 2332 + () => getAppviewRecordCount('sh.tangled.repo.countPulls', repo.repoDid), 2333 + () => getPullCountFromBacklinks(repo), 2065 2334 ); 2066 2335 2067 2336 export const getPull = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => ··· 2395 2664 collection: asNsid(collection), 2396 2665 rkey: rkey.join('/'), 2397 2666 }; 2667 + }; 2668 + 2669 + const parseDirectRecordRef = (ref: string, collection: Nsid): ResourceUri | null => { 2670 + let normalized = ref.trim(); 2671 + try { 2672 + normalized = decodeURIComponent(normalized); 2673 + } catch { 2674 + // Keep the original route param when it is not percent-encoded. 2675 + } 2676 + 2677 + if (!normalized.startsWith('at://')) { 2678 + return null; 2679 + } 2680 + 2681 + try { 2682 + return parseAtUri(normalized).collection === collection ? (normalized as ResourceUri) : null; 2683 + } catch { 2684 + return null; 2685 + } 2398 2686 }; 2399 2687 2400 2688 export const extractBlobCid = (blob: unknown): string | null => {
+1
src/lib/api/issues.ts
··· 3 3 createIssueComment, 4 4 getIssue, 5 5 getIssueRecord, 6 + getRepoIssueCount, 6 7 listIssues, 7 8 listIssuesPage, 8 9 setIssueState,
+1
src/lib/api/pulls.ts
··· 4 4 fetchPullRoundPatch, 5 5 getPull, 6 6 getPullRecord, 7 + getRepoPullCount, 7 8 listPulls, 8 9 listPullsPage, 9 10 setPullStatus,
+4 -2
src/lib/repo-utils.ts
··· 91 91 export const blobHref = (repo: RepoContext, ref: string, path: string): string => 92 92 `/${repo.owner.handle}/${repo.slug}/blob/${encodeURIComponent(ref)}/${encodePath(path)}`; 93 93 94 + const encodeRouteParam = (value: string | number): string => encodeURIComponent(String(value)); 95 + 94 96 export const issueHref = (repo: RepoContext, issueRef: string | number): string => 95 - `/${repo.owner.handle}/${repo.slug}/issues/${issueRef}`; 97 + `/${repo.owner.handle}/${repo.slug}/issues/${encodeRouteParam(issueRef)}`; 96 98 97 99 export const pullHref = (repo: RepoContext, pullRef: string | number): string => 98 - `/${repo.owner.handle}/${repo.slug}/pulls/${pullRef}`; 100 + `/${repo.owner.handle}/${repo.slug}/pulls/${encodeRouteParam(pullRef)}`; 99 101 100 102 export const encodePath = (path: string): string => 101 103 path
+9 -9
src/pages/repo/issues.tsx
··· 154 154 > 155 155 <div class="flex flex-col gap-2"> 156 156 <div class="contents"> 157 - <For each={paginated()}> 158 - {(issue) => ( 159 - <A 160 - href={issueHref(repoQuery.data!, issue.rkey)} 161 - class="block rounded shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700 no-underline hover:underline" 162 - > 157 + <For each={paginated()}> 158 + {(issue) => ( 159 + <A 160 + href={issueHref(repoQuery.data!, issue.uri)} 161 + class="block rounded shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700 no-underline hover:underline" 162 + > 163 163 <div class="min-w-0"> 164 164 <div class="text-base text-gray-900 dark:text-gray-100"> 165 165 {issue.value.title}{' '} 166 - <span class="text-gray-500 dark:text-gray-400">#{issue.number}</span> 166 + <span class="text-gray-500 dark:text-gray-400">#{issue.number || issue.rkey}</span> 167 167 </div> 168 168 <div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 169 169 <StateBadge state={issue.state} kind="issue" /> ··· 227 227 try { 228 228 const created = await createIssue(agent, repo, { title: title(), body: body() }); 229 229 await client.invalidateQueries({ queryKey: issuesQueryKey(repo.repoDid) }); 230 - navigate(issueHref(repo, created.rkey)); 230 + navigate(issueHref(repo, created.uri)); 231 231 } catch (cause) { 232 232 setError(cause instanceof Error ? cause.message : 'Failed to create issue'); 233 233 } finally { ··· 396 396 <header class="pb-2"> 397 397 <h1 class="m-0 text-2xl"> 398 398 {detail().issue.value.title}{' '} 399 - <span class="text-gray-500 dark:text-gray-400">#{detail().issue.number}</span> 399 + <span class="text-gray-500 dark:text-gray-400">#{detail().issue.number || detail().issue.rkey}</span> 400 400 </h1> 401 401 </header> 402 402
+27 -27
src/pages/repo/pulls.tsx
··· 173 173 > 174 174 <div class="flex flex-col gap-2"> 175 175 <div class="contents"> 176 - <For each={paginated()}> 177 - {(pull) => ( 178 - <A 179 - href={pullHref(repoQuery.data!, pull.number)} 180 - class="block rounded shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700 no-underline hover:underline" 181 - > 176 + <For each={paginated()}> 177 + {(pull) => ( 178 + <A 179 + href={pullHref(repoQuery.data!, pull.uri)} 180 + class="block rounded shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700 no-underline hover:underline" 181 + > 182 182 <div class="min-w-0"> 183 183 <div class="text-base text-gray-900 dark:text-gray-100"> 184 184 {pull.value.title}{' '} 185 - <span class="text-gray-500 dark:text-gray-400">#{pull.number}</span> 185 + <span class="text-gray-500 dark:text-gray-400">#{pull.number || pull.rkey}</span> 186 186 </div> 187 187 <div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 188 188 <StateBadge state={pull.state} kind="pull" /> ··· 337 337 338 338 setSubmitting(true); 339 339 setError(null); 340 - try { 341 - const created = await createPull(agent, repo, { 342 - title: title(), 343 - body: body(), 344 - sourceBranch: mode === 'patch' ? targetBranch() : sourceBranch(), 345 - targetBranch: targetBranch(), 346 - patch, 347 - }); 348 - await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) }); 349 - navigate(pullHref(repo, created.rkey)); 350 - } catch (cause) { 351 - setError(cause instanceof Error ? cause.message : 'Failed to create pull request'); 352 - } finally { 353 - setSubmitting(false); 354 - } 355 - }; 340 + try { 341 + const created = await createPull(agent, repo, { 342 + title: title(), 343 + body: body(), 344 + sourceBranch: mode === 'patch' ? targetBranch() : sourceBranch(), 345 + targetBranch: targetBranch(), 346 + patch, 347 + }); 348 + await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) }); 349 + navigate(pullHref(repo, created.uri)); 350 + } catch (cause) { 351 + setError(cause instanceof Error ? cause.message : 'Failed to create pull request'); 352 + } finally { 353 + setSubmitting(false); 354 + } 355 + }; 356 356 357 357 return ( 358 358 <RepoFrame active="pulls"> ··· 883 883 <div class="untangled-pr-header-grid"> 884 884 <section class="untangled-pr-summary-card"> 885 885 <header> 886 - <h1 class="m-0 text-2xl"> 887 - {detail().pull.value.title}{' '} 888 - <span class="text-gray-500 dark:text-gray-400">#{detail().pull.number}</span> 889 - </h1> 886 + <h1 class="m-0 text-2xl"> 887 + {detail().pull.value.title}{' '} 888 + <span class="text-gray-500 dark:text-gray-400">#{detail().pull.number || detail().pull.rkey}</span> 889 + </h1> 890 890 </header> 891 891 892 892 <div class="mt-3 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
+13 -1
src/pages/repo/shared.tsx
··· 2 2 import { A, type RouteSectionProps, useParams } from '@solidjs/router'; 3 3 import { createQuery } from '@tanstack/solid-query'; 4 4 import { For, Match, Show, Switch, createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Component, type JSX } from 'solid-js'; 5 - import { createRepoStar, deleteRepoStar, getRepo, getRepoStarSummary } from '../../lib/api'; 5 + import { createRepoStar, deleteRepoStar, getRepo, getRepoIssueCount, getRepoPullCount, getRepoStarSummary } from '../../lib/api'; 6 6 import { useAuth } from '../../lib/auth'; 7 7 import { useLiveEvents } from '../../lib/live-events'; 8 8 import { Avatar, ErrorState, cardStyles } from '../../components/common'; ··· 62 62 queryKey: ['repo-star-summary', repoQuery.data?.repoDid, auth.currentDid()], 63 63 enabled: Boolean(repoQuery.data), 64 64 queryFn: async () => getRepoStarSummary(repoQuery.data!, auth.currentDid()), 65 + })); 66 + const issueCountQuery = createQuery(() => ({ 67 + queryKey: [...issuesQueryKey(repoQuery.data?.repoDid ?? ''), 'count'], 68 + enabled: Boolean(repoQuery.data), 69 + queryFn: async () => getRepoIssueCount(repoQuery.data!), 70 + })); 71 + const pullCountQuery = createQuery(() => ({ 72 + queryKey: [...pullsQueryKey(repoQuery.data?.repoDid ?? ''), 'count'], 73 + enabled: Boolean(repoQuery.data), 74 + queryFn: async () => getRepoPullCount(repoQuery.data!), 65 75 })); 66 76 const starSummaryLoading = createMemo(() => !starSummaryQuery.data && (starSummaryQuery.isLoading || starSummaryQuery.isFetching)); 67 77 const starBusy = createMemo(() => starWorking() || starSummaryLoading()); ··· 341 351 href={`/${repo().owner.handle}/${repo().slug}/issues`} 342 352 icon={<CircleDot class="size-4" />} 343 353 label="issues" 354 + meta={issueCountQuery.data === undefined ? undefined : String(issueCountQuery.data)} 344 355 /> 345 356 <RepoTabLink 346 357 active={props.active === 'pulls'} 347 358 href={`/${repo().owner.handle}/${repo().slug}/pulls`} 348 359 icon={<GitPullRequest class="size-4" />} 349 360 label="pulls" 361 + meta={pullCountQuery.data === undefined ? undefined : String(pullCountQuery.data)} 350 362 /> 351 363 </div> 352 364 </nav>
+25 -25
src/views/search.tsx
··· 218 218 return ( 219 219 <Show when={parentQuery.data} fallback={<span>comment</span>}> 220 220 {(parent) => { 221 - const repoDid = createMemo(() => parent().kind === 'issue' 222 - ? (parent().record.value as { repo: Did }).repo 223 - : (parent().record.value as { target: { repo: Did } }).target.repo); 224 - return ( 225 - <RepoThreadLink kind={parent().kind} repoDid={repoDid()} ref={parent().record.rkey}> 226 - comment on {parent().kind} 227 - </RepoThreadLink> 228 - ); 229 - }} 230 - </Show> 221 + const repoDid = createMemo(() => parent().kind === 'issue' 222 + ? (parent().record.value as { repo: Did }).repo 223 + : (parent().record.value as { target: { repo: Did } }).target.repo); 224 + return ( 225 + <RepoThreadLink kind={parent().kind} repoDid={repoDid()} ref={parent().record.uri}> 226 + comment on {parent().kind} 227 + </RepoThreadLink> 228 + ); 229 + }} 230 + </Show> 231 231 ); 232 232 }; 233 233 ··· 290 290 <div class="flex items-center min-w-0 flex-1 mr-2"> 291 291 <SearchResultIcon hit={props.hit} /> 292 292 <Switch fallback={<span class="truncate min-w-0">{title()}</span>}> 293 - <Match when={props.hit.nsid === 'sh.tangled.repo'}> 294 - <A href={`/${props.hit.author.handle}/${repoName()}`} class="truncate min-w-0"> 295 - {props.hit.author.handle}/{repoName()} 296 - </A> 297 - </Match> 298 - <Match when={props.hit.nsid === 'sh.tangled.repo.issue'}> 299 - <RepoThreadLink kind="issue" repoDid={(props.hit.value as { repo: Did }).repo} ref={props.hit.rkey}> 300 - {title()} 301 - </RepoThreadLink> 302 - </Match> 303 - <Match when={props.hit.nsid === 'sh.tangled.repo.pull'}> 304 - <RepoThreadLink kind="pull" repoDid={(props.hit.value as { target: { repo: Did } }).target.repo} ref={props.hit.rkey}> 305 - {title()} 306 - </RepoThreadLink> 307 - </Match> 293 + <Match when={props.hit.nsid === 'sh.tangled.repo'}> 294 + <A href={`/${props.hit.author.handle}/${repoName()}`} class="truncate min-w-0"> 295 + {props.hit.author.handle}/{repoName()} 296 + </A> 297 + </Match> 298 + <Match when={props.hit.nsid === 'sh.tangled.repo.issue'}> 299 + <RepoThreadLink kind="issue" repoDid={(props.hit.value as { repo: Did }).repo} ref={props.hit.uri}> 300 + {title()} 301 + </RepoThreadLink> 302 + </Match> 303 + <Match when={props.hit.nsid === 'sh.tangled.repo.pull'}> 304 + <RepoThreadLink kind="pull" repoDid={(props.hit.value as { target: { repo: Did } }).target.repo} ref={props.hit.uri}> 305 + {title()} 306 + </RepoThreadLink> 307 + </Match> 308 308 <Match when={props.hit.nsid === 'sh.tangled.repo.issue.comment' || props.hit.nsid === 'sh.tangled.repo.pull.comment'}> 309 309 <CommentParentLink hit={props.hit} /> 310 310 </Match>