From 49d53f8ba149f7e7dc1531fe86ecca67f45554c7 Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Sat, 1 Aug 2026 19:36:51 -0400 Subject: [PATCH] watch repos --- src/index.css | 17 +++++++ src/lib/api/issues.ts | 29 +++++++++++- src/lib/api/pulls.ts | 26 ++++++++++- src/pages/repo/shared.tsx | 4 +- src/pages/watching.tsx | 96 +++++++++++++++++++++++++++++++++------ 5 files changed, 154 insertions(+), 18 deletions(-) diff --git a/src/index.css b/src/index.css index b8737a4..4f4299d 100644 --- a/src/index.css +++ b/src/index.css @@ -3948,6 +3948,23 @@ pre{ align-items: center; } +.untangled-watched-skeleton-row{ + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.4fr) auto; + gap: 1.25rem; + align-items: center; + min-height: 4.5rem; + padding: 1rem 0.25rem; + border-bottom: 1px solid rgb(229 231 235); +} + +.untangled-watched-skeleton-repo, .untangled-watched-skeleton-latest{ + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.5rem; +} + .untangled-watched-list-heading > *, .untangled-watched-repo-summary > *{ min-width: 0; } diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index 986b969..c5455ce 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -328,7 +328,34 @@ export const listIssuesPage = async ( ); export const getRepoIssueCount = async (repo: RepoContext): Promise => - (await listIssues(repo)).filter((issue) => issue.state === 'open').length; + countOpenIssuesFromBacklinks(repo); + +export const getAppviewOpenIssueCount = async (repo: RepoContext): Promise => + (await listAllAppviewRecords( + 'sh.tangled.repo.listIssues', + repo.repoDid, + { state: 'open' }, + )).length; + +const countOpenIssuesFromBacklinks = async (repo: RepoContext): Promise => { + const refs = await getUniqueBacklinkRefs(repo.repoDid, [ + 'sh.tangled.repo.issue:repoDid', + 'sh.tangled.repo.issue:repo', + ]); + let nextIndex = 0; + let count = 0; + const worker = async () => { + while (nextIndex < refs.length) { + const ref = refs[nextIndex++]; + if (await getLatestIssueState(`at://${ref.did}/${ref.collection}/${ref.rkey}` as ResourceUri) === 'open') { + count += 1; + } + } + }; + + await Promise.all(Array.from({ length: Math.min(6, refs.length) }, () => worker())); + return count; +}; export const getIssue = async (repo: RepoContext, issueRef: string): Promise => appviewFallback( diff --git a/src/lib/api/pulls.ts b/src/lib/api/pulls.ts index 176b7b5..3fe2009 100644 --- a/src/lib/api/pulls.ts +++ b/src/lib/api/pulls.ts @@ -349,7 +349,31 @@ export const listPullsPage = async ( ); export const getRepoPullCount = async (repo: RepoContext): Promise => - (await listPullsFromBacklinks(repo)).filter((pull) => pull.state === 'open').length; + countOpenPullsFromBacklinks(repo); + +export const getAppviewOpenPullCount = async (repo: RepoContext): Promise => + (await listAllAppviewRecords( + 'sh.tangled.repo.listPulls', + repo.repoDid, + { state: 'open' }, + )).length; + +const countOpenPullsFromBacklinks = async (repo: RepoContext): Promise => { + const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); + let nextIndex = 0; + let count = 0; + const worker = async () => { + while (nextIndex < refs.length) { + const ref = refs[nextIndex++]; + if (await getLatestPullStatus(`at://${ref.did}/${ref.collection}/${ref.rkey}` as ResourceUri) === 'open') { + count += 1; + } + } + }; + + await Promise.all(Array.from({ length: Math.min(6, refs.length) }, () => worker())); + return count; +}; export const getPull = async (repo: RepoContext, pullRef: string): Promise => appviewFallback( diff --git a/src/pages/repo/shared.tsx b/src/pages/repo/shared.tsx index b64a395..35efe44 100644 --- a/src/pages/repo/shared.tsx +++ b/src/pages/repo/shared.tsx @@ -257,7 +257,7 @@ export const RepoFrame: Component<{ try { const result = await createWatch(agent, repo.record.uri); setWatchRkey(result.rkey); - await queryClient.invalidateQueries({ queryKey: ['watched-repos', auth.currentDid()] }); + await queryClient.invalidateQueries({ queryKey: ['watched-repos-v2', auth.currentDid()] }); } catch (cause) { setOptimisticWatched(null); setWatchError(getErrorMessage(cause)); @@ -279,7 +279,7 @@ export const RepoFrame: Component<{ try { await deleteWatch(agent, rkey); setWatchRkey(null); - await queryClient.invalidateQueries({ queryKey: ['watched-repos', auth.currentDid()] }); + await queryClient.invalidateQueries({ queryKey: ['watched-repos-v2', auth.currentDid()] }); } catch (cause) { setOptimisticWatched(null); setWatchError(getErrorMessage(cause)); diff --git a/src/pages/watching.tsx b/src/pages/watching.tsx index c13de50..332875b 100644 --- a/src/pages/watching.tsx +++ b/src/pages/watching.tsx @@ -1,11 +1,11 @@ import { A } from '@solidjs/router'; -import { Ban, ChevronDown, ChevronLeft, ChevronRight, CircleDot, Eye, GitCommitHorizontal, GitMerge, GitPullRequest, GitPullRequestClosed, LoaderCircle, SquareChartGantt } from 'lucide-solid'; +import { Ban, ChevronDown, ChevronLeft, ChevronRight, CircleDot, Eye, GitCommitHorizontal, GitMerge, GitPullRequest, GitPullRequestClosed, SquareChartGantt } from 'lucide-solid'; import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from 'solid-js'; -import { ErrorState } from '../components/common'; +import { ErrorState, SkeletonBlock } from '../components/common'; import { getRepoDefaultBranch, getRepoByUri, getRepoLog, type CommitEntry, type GitHashValue, type RepoContext } from '../lib/api/repos'; -import { listRecentOpenIssues, type IssueSummary } from '../lib/api/issues'; -import { listRecentOpenPulls, type PullSummary } from '../lib/api/pulls'; +import { getAppviewOpenIssueCount, listRecentOpenIssues, type IssueSummary } from '../lib/api/issues'; +import { getAppviewOpenPullCount, listRecentOpenPulls, type PullSummary } from '../lib/api/pulls'; import { listWatchRecords, type WatchRecord } from '../lib/api/watches'; import { useAuth } from '../lib/auth'; import { createQuery, useQueryClient } from '../lib/atproto-query'; @@ -17,6 +17,8 @@ type WatchedRepo = { repo: RepoContext; issues: IssueSummary[]; pulls: PullSummary[]; + openIssueCount: number | null; + openPullCount: number | null; commits: CommitEntry[]; }; @@ -50,6 +52,8 @@ const loadWatchedRepo = async (watch: WatchRecord): Promise repo, issues, pulls, + openIssueCount: null, + openPullCount: null, commits: log.commits.slice(0, 30), }; } catch { @@ -123,14 +127,19 @@ const WatchedRepoRow: Component<{ item: WatchedRepo; expanded: boolean; onToggle 0 || openPulls().length > 0} - fallback={—} + when={props.item.openIssueCount !== null && props.item.openPullCount !== null} + fallback={…} > - 0}> - {openIssues().length} issues - - 0}> - {openPulls().length} prs + 0 || props.item.openPullCount! > 0} + fallback={—} + > + 0}> + {props.item.openIssueCount} issues + + 0}> + {props.item.openPullCount} prs + @@ -191,13 +200,42 @@ const WatchedRepoRow: Component<{ item: WatchedRepo; expanded: boolean; onToggle }; const WATCHED_PAGE_SIZE = 3; +const WATCHED_REPOS_QUERY_KEY = 'watched-repos-v2'; +const WATCHED_SKELETON_ROWS = [0, 1, 2]; + +const WatchedReposSkeleton: Component = () => ( +
+ + + {() => ( + + )} + +
+); export const WatchedReposPanel: Component = () => { const auth = useAuth(); const live = useLiveEvents(); const queryClient = useQueryClient(); const watchesQuery = createQuery(() => ({ - queryKey: ['watched-repos', auth.currentDid()], + queryKey: [WATCHED_REPOS_QUERY_KEY, auth.currentDid()], enabled: Boolean(auth.currentDid()), staleTime: 86_400_000, queryFn: async () => { @@ -210,6 +248,7 @@ export const WatchedReposPanel: Component = () => { const watchCount = createMemo(() => watchesQuery.data?.length ?? 0); const [watchedPage, setWatchedPage] = createSignal(0); const [selectedRepoUri, setSelectedRepoUri] = createSignal(null); + const countRefreshes = new Set(); const watchedPageCount = createMemo(() => Math.max(1, Math.ceil(watchCount() / WATCHED_PAGE_SIZE))); const visibleWatchedRepos = createMemo(() => { const items = watchesQuery.data ?? []; @@ -222,6 +261,34 @@ export const WatchedReposPanel: Component = () => { setWatchedPage(watchedPageCount() - 1); } }); + + createEffect(() => { + const items = watchesQuery.data; + const did = auth.currentDid(); + if (!did || !items) { + return; + } + + for (const item of items) { + const repoDid = item.repo.repoDid; + if (countRefreshes.has(repoDid)) { + continue; + } + countRefreshes.add(repoDid); + void Promise.all([ + getAppviewOpenIssueCount(item.repo), + getAppviewOpenPullCount(item.repo), + ]).then(([openIssueCount, openPullCount]) => { + queryClient.setQueryData([WATCHED_REPOS_QUERY_KEY, did], (current) => + current?.map((entry) => entry.repo.repoDid === repoDid + ? { ...entry, openIssueCount, openPullCount } + : entry), + ); + }).catch(() => { + countRefreshes.delete(repoDid); + }); + } + }); const processedLiveEvents = new Set(); createEffect(() => { @@ -284,7 +351,8 @@ export const WatchedReposPanel: Component = () => { } if (shouldRefresh) { - void queryClient.invalidateQueries({ queryKey: ['watched-repos', auth.currentDid()] }); + countRefreshes.clear(); + void queryClient.invalidateQueries({ queryKey: [WATCHED_REPOS_QUERY_KEY, auth.currentDid()] }); } }); @@ -297,7 +365,7 @@ export const WatchedReposPanel: Component = () => {

sign in to watch repositories

watch a repository to keep its recent issues, pull requests, and commits in one place.

}> - loading your watched repos...}> + }> }> 0} fallback={

nothing watched yet

open a repository and choose watch to start your daily brief.

find a repository
}>
-- 2.51.2