From f60907db891a21601114f0ba49502199a8c63ccf Mon Sep 17 00:00:00 2001 From: Prem Sathisha Etagi Date: Thu, 16 Apr 2026 00:14:50 -0700 Subject: [PATCH 1/5] Add repositories page --- .../src/components/filters/filter-bar.tsx | 44 +++- .../dashboard/src/components/filters/index.ts | 7 +- .../layouts/dashboard-mobile-nav.tsx | 2 + .../components/layouts/dashboard-topbar.tsx | 2 + .../src/components/repo/repository-card.tsx | 87 +++++++ .../src/lib/command-palette/registry.ts | 10 + apps/dashboard/src/lib/github.functions.ts | 110 +++++++- apps/dashboard/src/lib/github.types.ts | 1 + apps/dashboard/src/routeTree.gen.ts | 21 ++ .../dashboard/src/routes/_protected/repos.tsx | 244 ++++++++++++++++++ .../routes/_protected/settings/shortcuts.tsx | 1 + packages/icons/src/index.ts | 1 + 12 files changed, 512 insertions(+), 18 deletions(-) create mode 100644 apps/dashboard/src/components/repo/repository-card.tsx create mode 100644 apps/dashboard/src/routes/_protected/repos.tsx diff --git a/apps/dashboard/src/components/filters/filter-bar.tsx b/apps/dashboard/src/components/filters/filter-bar.tsx index a74eab1..468d006 100644 --- a/apps/dashboard/src/components/filters/filter-bar.tsx +++ b/apps/dashboard/src/components/filters/filter-bar.tsx @@ -22,8 +22,10 @@ import type { export const FilterBar = memo(function FilterBar({ state, + searchPlaceholder = "Search by title, author, repo, or #…", }: { state: ListFilterState; + searchPlaceholder?: string; }) { return (
@@ -31,6 +33,7 @@ export const FilterBar = memo(function FilterBar({ ({ ); }) as (props: { state: ListFilterState; + searchPlaceholder?: string; }) => React.ReactNode; // ── Search Input ─────────────────────────────────────────────────────── @@ -85,9 +89,11 @@ export const FilterBar = memo(function FilterBar({ function SearchInput({ value, onChange, + placeholder, }: { value: string; onChange: (v: string) => void; + placeholder: string; }) { return (
@@ -96,7 +102,7 @@ function SearchInput({ type="text" value={value} onChange={(e) => onChange(e.target.value)} - placeholder="Search by title, author, repo…" + placeholder={placeholder} className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" /> {value && ( @@ -274,17 +280,19 @@ function AddFilterButton({ const [open, setOpen] = useState(false); const [selectedFieldId, setSelectedFieldId] = useState(null); const ref = useRef(null); + const directFilterDef = filterDefs.length === 1 ? filterDefs[0] : null; const close = () => { setOpen(false); setSelectedFieldId(null); }; const activeFieldIds = new Set(activeFilters.map((f) => f.fieldId)); - const selectedOptions = selectedFieldId - ? (availableOptions.get(selectedFieldId) ?? []) + const activeFieldId = directFilterDef?.id ?? selectedFieldId; + const selectedOptions = activeFieldId + ? (availableOptions.get(activeFieldId) ?? []) : []; const selectedValues = - activeFilters.find((f) => f.fieldId === selectedFieldId)?.values ?? + activeFilters.find((f) => f.fieldId === activeFieldId)?.values ?? new Set(); return ( @@ -306,13 +314,27 @@ function AddFilterButton({ {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss */}
- - {selectedFieldId && ( + {directFilterDef ? ( + { + if (selectedValues.has(value)) { + onRemoveValue(directFilterDef.id, value); + } else { + onAdd(directFilterDef.id, value); + } + }} + /> + ) : ( + + )} + {!directFilterDef && selectedFieldId && ( [ { to: "/", label: "Overview", icon: HomeIcon }, { to: "/inbox", label: "Inbox", icon: InboxIcon, dot: hasUnread }, + { to: "/repos", label: "Repositories", icon: FolderLibraryIcon }, { to: "/pulls", label: "Pull Requests", diff --git a/apps/dashboard/src/components/repo/repository-card.tsx b/apps/dashboard/src/components/repo/repository-card.tsx new file mode 100644 index 0000000..6040764 --- /dev/null +++ b/apps/dashboard/src/components/repo/repository-card.tsx @@ -0,0 +1,87 @@ +import { GitForkIcon, StarIcon } from "@diffkit/icons"; +import { Link } from "@tanstack/react-router"; + +type RepositoryCardRepo = { + name: string; + owner: string; + description: string | null; + language: string | null; + stars: number; + forks?: number; + isPrivate: boolean; +}; + +const languageColors: Record = { + Astro: "#ff5a03", + CSS: "#563d7c", + Go: "#00add8", + HTML: "#e34c26", + JavaScript: "#f1e05a", + MDX: "#fcb32c", + Python: "#3572a5", + Rust: "#dea584", + Shell: "#89e051", + Swift: "#f05138", + TypeScript: "#3178c6", +}; + +export function RepositoryCard({ repo }: { repo: RepositoryCardRepo }) { + return ( + +
+ + {repo.name} + + + {repo.isPrivate ? "Private" : "Public"} + +
+ +
+ {repo.description && ( +

+ {repo.description} +

+ )} +
+ +
+ {repo.language && ( + + + {repo.language} + + )} + {repo.stars > 0 && ( + + + {formatCount(repo.stars)} + + )} + {typeof repo.forks === "number" && repo.forks > 0 && ( + + + {formatCount(repo.forks)} + + )} +
+ + ); +} + +function formatCount(count: number): string { + if (count >= 1000) { + return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; + } + return count.toString(); +} diff --git a/apps/dashboard/src/lib/command-palette/registry.ts b/apps/dashboard/src/lib/command-palette/registry.ts index d9c20fb..a92151b 100644 --- a/apps/dashboard/src/lib/command-palette/registry.ts +++ b/apps/dashboard/src/lib/command-palette/registry.ts @@ -1,6 +1,7 @@ import { DashboardIcon, ExternalLinkIcon, + FolderLibraryIcon, GitPullRequestIcon, IssuesIcon, ReviewsIcon, @@ -47,6 +48,15 @@ registerCommands([ shortcut: ["G", "H"], action: { type: "navigate", to: "/" }, }, + { + id: "nav:repos", + label: "Go to Repositories", + group: "Pages", + icon: FolderLibraryIcon, + keywords: ["repos", "code"], + shortcut: ["G", "O"], + action: { type: "navigate", to: "/repos" }, + }, { id: "nav:pulls", label: "Go to Pull Requests", diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index c287343..77c4fa8 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -2000,7 +2000,100 @@ async function getGitHubContextForRepository(input: { owner: string; repo: string; }) { - return getGitHubContextForOwner(input.owner); + return getOrCreateCachedContext( + `repo:${input.owner}/${input.repo}`, + async () => { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + const { installations } = await getGitHubAppUserInstallations( + context.session.user.id, + ); + const installation = findGitHubAppInstallationForOwner( + installations, + input.owner, + ); + if (!installation) { + debug("github-access", "no installation for repo owner, using OAuth", { + owner: input.owner, + repo: input.repo, + }); + return context; + } + + if ( + installation.repositorySelection === "selected" && + !(await appInstallationHasRepositoryAccess( + context, + installation, + input, + )) + ) { + debug( + "github-access", + "installation does not include repo, using OAuth", + { + owner: input.owner, + repo: input.repo, + installationId: installation.id, + }, + ); + return context; + } + + const installationContext = await getGitHubContextForInstallation( + context, + installation, + ); + if (!installationContext) { + console.error( + "[github-access] installation client failed, falling back to OAuth token", + input.owner, + ); + return context; + } + + return installationContext; + }, + ); +} + +async function appInstallationHasRepositoryAccess( + context: GitHubContext, + installation: GitHubAppInstallation, + params: RepoCollaboratorsInput, +) { + if (installation.repositorySelection === "all") { + return true; + } + + if (installation.repositorySelection !== "selected") { + return false; + } + + try { + const repositories = await listPaginatedGitHubItems({ + request: (page) => + context.octokit.rest.apps.listInstallationReposForAuthenticatedUser({ + installation_id: installation.id, + page, + per_page: 100, + }), + getItems: (payload) => + ((payload as GitHubInstallationRepositoriesPayload).repositories ?? + []) as NonNullable< + GitHubInstallationRepositoriesPayload["repositories"] + >, + }); + + return repositories.some((repository) => + repositoryMatchesInstallationRepository(repository, params), + ); + } catch { + return false; + } } function findGitHubAppInstallationForOwner( @@ -5047,19 +5140,23 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( } const [repos, accessIndex] = await Promise.all([ - getCachedGitHubRequest({ + getCachedPaginatedGitHubRequest< + AuthenticatedUserRepo, + UserRepoSummary[] + >({ context, resource: "repos.list", - params: { sort: "updated", perPage: 10 }, + params: { sort: "updated", perPage: 100 }, freshForMs: githubCachePolicy.reposList.staleTimeMs, signalKeys: [githubRevalidationSignalKeys.installationAccess], namespaceKeys: ["repos.list"], cacheMode: "split", - request: (headers) => + pageSize: 100, + request: (page) => context.octokit.rest.repos.listForAuthenticatedUser({ sort: "updated", - per_page: 10, - headers, + per_page: 100, + page, }), mapData: (repos) => repos.map( @@ -5071,6 +5168,7 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( stars: repo.stargazers_count, language: repo.language, updatedAt: repo.updated_at, + createdAt: repo.created_at, isPrivate: repo.private, url: repo.html_url, owner: repo.owner.login, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 118af65..a110db8 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -36,6 +36,7 @@ export type UserRepoSummary = { stars: number; language: string | null; updatedAt: string | null; + createdAt: string | null; isPrivate: boolean; url: string; owner: string; diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 848019e..cd0b61b 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as SplatRouteImport } from './routes/$' import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' import { Route as ProtectedSettingsRouteImport } from './routes/_protected/settings' import { Route as ProtectedReviewsRouteImport } from './routes/_protected/reviews' +import { Route as ProtectedReposRouteImport } from './routes/_protected/repos' import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls' import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' import { Route as ProtectedInboxRouteImport } from './routes/_protected/inbox' @@ -82,6 +83,11 @@ const ProtectedReviewsRoute = ProtectedReviewsRouteImport.update({ path: '/reviews', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedReposRoute = ProtectedReposRouteImport.update({ + id: '/repos', + path: '/repos', + getParentRoute: () => ProtectedRoute, +} as any) const ProtectedPullsRoute = ProtectedPullsRouteImport.update({ id: '/pulls', path: '/pulls', @@ -196,6 +202,7 @@ export interface FileRoutesByFullPath { '/inbox': typeof ProtectedInboxRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute + '/repos': typeof ProtectedReposRoute '/reviews': typeof ProtectedReviewsRoute '/settings': typeof ProtectedSettingsRouteWithChildren '/settings/shortcuts': typeof ProtectedSettingsShortcutsRoute @@ -224,6 +231,7 @@ export interface FileRoutesByTo { '/inbox': typeof ProtectedInboxRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute + '/repos': typeof ProtectedReposRoute '/reviews': typeof ProtectedReviewsRoute '/': typeof ProtectedIndexRoute '/settings/shortcuts': typeof ProtectedSettingsShortcutsRoute @@ -254,6 +262,7 @@ export interface FileRoutesById { '/_protected/inbox': typeof ProtectedInboxRoute '/_protected/issues': typeof ProtectedIssuesRoute '/_protected/pulls': typeof ProtectedPullsRoute + '/_protected/repos': typeof ProtectedReposRoute '/_protected/reviews': typeof ProtectedReviewsRoute '/_protected/settings': typeof ProtectedSettingsRouteWithChildren '/_protected/': typeof ProtectedIndexRoute @@ -286,6 +295,7 @@ export interface FileRouteTypes { | '/inbox' | '/issues' | '/pulls' + | '/repos' | '/reviews' | '/settings' | '/settings/shortcuts' @@ -314,6 +324,7 @@ export interface FileRouteTypes { | '/inbox' | '/issues' | '/pulls' + | '/repos' | '/reviews' | '/' | '/settings/shortcuts' @@ -343,6 +354,7 @@ export interface FileRouteTypes { | '/_protected/inbox' | '/_protected/issues' | '/_protected/pulls' + | '/_protected/repos' | '/_protected/reviews' | '/_protected/settings' | '/_protected/' @@ -442,6 +454,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedReviewsRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/repos': { + id: '/_protected/repos' + path: '/repos' + fullPath: '/repos' + preLoaderRoute: typeof ProtectedReposRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/pulls': { id: '/_protected/pulls' path: '/pulls' @@ -595,6 +614,7 @@ interface ProtectedRouteChildren { ProtectedInboxRoute: typeof ProtectedInboxRoute ProtectedIssuesRoute: typeof ProtectedIssuesRoute ProtectedPullsRoute: typeof ProtectedPullsRoute + ProtectedReposRoute: typeof ProtectedReposRoute ProtectedReviewsRoute: typeof ProtectedReviewsRoute ProtectedSettingsRoute: typeof ProtectedSettingsRouteWithChildren ProtectedIndexRoute: typeof ProtectedIndexRoute @@ -614,6 +634,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedInboxRoute: ProtectedInboxRoute, ProtectedIssuesRoute: ProtectedIssuesRoute, ProtectedPullsRoute: ProtectedPullsRoute, + ProtectedReposRoute: ProtectedReposRoute, ProtectedReviewsRoute: ProtectedReviewsRoute, ProtectedSettingsRoute: ProtectedSettingsRouteWithChildren, ProtectedIndexRoute: ProtectedIndexRoute, diff --git a/apps/dashboard/src/routes/_protected/repos.tsx b/apps/dashboard/src/routes/_protected/repos.tsx new file mode 100644 index 0000000..bdd84b8 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/repos.tsx @@ -0,0 +1,244 @@ +import { + FilterIcon, + FolderLibraryIcon, + LockIcon, + ViewIcon, +} from "@diffkit/icons"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { createElement, useMemo } from "react"; +import { + applyFilters, + type FilterableItem, + FilterBar, + type FilterDefinition, + getFilterCookie, + type SortOption, + useListFilters, +} from "#/components/filters"; +import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; +import { RepositoryCard } from "#/components/repo/repository-card"; +import { githubUserReposQueryOptions } from "#/lib/github.query"; +import type { UserRepoSummary } from "#/lib/github.types"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +export const Route = createFileRoute("/_protected/repos")({ + ssr: false, + loader: async ({ context }) => { + const scope = { userId: context.user.id }; + void context.queryClient.prefetchQuery(githubUserReposQueryOptions(scope)); + const filterStore = await getFilterCookie(); + return { filterStore }; + }, + pendingComponent: DashboardContentLoading, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Repositories"), + description: "Your GitHub repositories in Diffkit.", + robots: "noindex", + }), + component: RepositoriesPage, +}); + +type RepositoryFilterItem = FilterableItem & { + repo: UserRepoSummary; + isPrivate: boolean; +}; + +const repositoryFilterDefs: FilterDefinition[] = [ + { + id: "visibility", + label: "Visibility", + icon: FilterIcon, + extractOptions: (items) => { + const hasPublic = items.some((item) => !asRepo(item).isPrivate); + const hasPrivate = items.some((item) => asRepo(item).isPrivate); + return [ + hasPublic + ? { + value: "public", + label: "Public", + icon: createElement(ViewIcon, { + size: 14, + className: "text-muted-foreground", + }), + } + : null, + hasPrivate + ? { + value: "private", + label: "Private", + icon: createElement(LockIcon, { + size: 14, + className: "text-muted-foreground", + }), + } + : null, + ].filter((option) => option !== null); + }, + match: (item, values) => + values.has(asRepo(item).isPrivate ? "private" : "public"), + }, +]; + +const repositorySortOptions: SortOption[] = [ + { + id: "updated", + label: "Recently updated", + compare: (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt), + }, + { + id: "created", + label: "Newest first", + compare: (a, b) => getTime(b.createdAt) - getTime(a.createdAt), + }, + { + id: "created-asc", + label: "Oldest first", + compare: (a, b) => getTime(a.createdAt) - getTime(b.createdAt), + }, + { + id: "title", + label: "Title A-Z", + compare: (a, b) => a.title.localeCompare(b.title), + }, +]; + +function RepositoriesPage() { + const { filterStore } = Route.useLoaderData(); + const { user } = Route.useRouteContext(); + const scope = useMemo(() => ({ userId: user.id }), [user.id]); + const hasMounted = useHasMounted(); + const reposQuery = useQuery({ + ...githubUserReposQueryOptions(scope), + enabled: hasMounted, + }); + + const filterItems = useMemo( + () => reposQuery.data?.map(toRepositoryFilterItem) ?? [], + [reposQuery.data], + ); + const filterState = useListFilters({ + pageId: "repos", + items: filterItems, + filterDefs: repositoryFilterDefs, + sortOptions: repositorySortOptions, + defaultSortId: "updated", + initialStore: filterStore, + }); + const filteredRepos = useMemo( + () => applyFilters(filterItems, filterState).map((item) => item.repo), + [filterItems, filterState], + ); + + if (reposQuery.error) throw reposQuery.error; + + if (!reposQuery.data) { + return ; + } + + const repos = reposQuery.data; + const publicCount = repos.filter((repo) => !repo.isPrivate).length; + const privateCount = repos.length - publicCount; + + return ( +
+
+ + +
+ + + {repos.length === 0 ? ( +
+ No repositories found. +
+ ) : filteredRepos.length === 0 ? ( +
+ No repositories match these filters. +
+ ) : ( +
+ {filteredRepos.map((repo) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function RepositoryMetricCard({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; + label: string; + value: number; +}) { + return ( +
+
+
+ +
+

{label}

+
+

{value}

+
+ ); +} + +function asRepo(item: FilterableItem) { + return item as RepositoryFilterItem; +} + +function getTime(value: unknown) { + return typeof value === "string" ? Date.parse(value) || 0 : 0; +} + +function toRepositoryFilterItem(repo: UserRepoSummary): RepositoryFilterItem { + return { + ...repo, + repo, + title: repo.name, + updatedAt: repo.updatedAt ?? "", + createdAt: repo.createdAt ?? "", + comments: 0, + author: null, + repository: { fullName: repo.fullName }, + state: repo.isPrivate ? "private" : "public", + }; +} diff --git a/apps/dashboard/src/routes/_protected/settings/shortcuts.tsx b/apps/dashboard/src/routes/_protected/settings/shortcuts.tsx index 518f1b5..fe5bc3e 100644 --- a/apps/dashboard/src/routes/_protected/settings/shortcuts.tsx +++ b/apps/dashboard/src/routes/_protected/settings/shortcuts.tsx @@ -202,6 +202,7 @@ const shortcutGroups: ShortcutGroup[] = [ { keys: ["G", "H"], description: "Go to Overview" }, { keys: ["G", "G"], description: "Open Current Page on GitHub" }, { keys: ["G", "U"], description: "Go to Profile" }, + { keys: ["G", "O"], description: "Go to Repositories" }, { keys: ["G", "P"], description: "Go to Pull Requests" }, { keys: ["G", "I"], description: "Go to Issues" }, { keys: ["G", "R"], description: "Go to Reviews" }, diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 404ff81..b9a01d7 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -32,6 +32,7 @@ export { Download02Icon as DownloadIcon, DragDropVerticalIcon as GripVerticalIcon, File02Icon as FileIcon, + FilterIcon, Folder01Icon as FolderIcon, FolderLibraryIcon, GitBranchIcon, From ee6c7b87413a4f0430438583caebee2e60dc171c Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 00:00:54 -0400 Subject: [PATCH 2/5] feat(dashboard): repos table with participation sparklines, user menu link, custom star icon - Table-style repo rows, visibility icons, recharts activity chart - GitHub participation stats API + caching; inactive styling when no commits - Move Repositories from topbar tabs into user menu - Custom Lucide-style StarIcon; SquareLock02 for private --- apps/dashboard/package.json | 1 + .../components/layouts/dashboard-topbar.tsx | 7 +- .../components/repo/repo-commit-sparkline.tsx | 111 +++++++ .../src/components/repo/repository-card.tsx | 87 ------ .../src/components/repo/repository-row.tsx | 148 +++++++++ apps/dashboard/src/lib/github-cache-policy.ts | 4 + apps/dashboard/src/lib/github.functions.ts | 162 +++++++--- apps/dashboard/src/lib/github.query.ts | 18 ++ apps/dashboard/src/lib/github.types.ts | 7 + .../dashboard/src/routes/_protected/repos.tsx | 22 +- packages/icons/src/index.ts | 3 +- packages/icons/src/star-icon.tsx | 27 ++ pnpm-lock.yaml | 283 ++++++++++++++++++ 13 files changed, 750 insertions(+), 130 deletions(-) create mode 100644 apps/dashboard/src/components/repo/repo-commit-sparkline.tsx delete mode 100644 apps/dashboard/src/components/repo/repository-card.tsx create mode 100644 apps/dashboard/src/components/repo/repository-row.tsx create mode 100644 packages/icons/src/star-icon.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index f643b15..8400058 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -44,6 +44,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-dropzone": "^15.0.0", + "recharts": "^3.8.1", "tailwindcss": "^4.1.18" }, "devDependencies": { diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index 9c790ce..50e2f86 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -109,7 +109,6 @@ export function DashboardTopbar({ () => [ { to: "/", label: "Overview", icon: HomeIcon }, { to: "/inbox", label: "Inbox", icon: InboxIcon, dot: hasUnread }, - { to: "/repos", label: "Repositories", icon: FolderLibraryIcon }, { to: "/pulls", label: "Pull Requests", @@ -278,6 +277,12 @@ export function DashboardTopbar({ + + + + Repositories + + diff --git a/apps/dashboard/src/components/repo/repo-commit-sparkline.tsx b/apps/dashboard/src/components/repo/repo-commit-sparkline.tsx new file mode 100644 index 0000000..9fc15eb --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-commit-sparkline.tsx @@ -0,0 +1,111 @@ +import { useQuery } from "@tanstack/react-query"; +import { useId } from "react"; +import { Area, AreaChart } from "recharts"; +import { + type GitHubQueryScope, + githubRepoParticipationQueryOptions, +} from "#/lib/github.query"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +const CHART_W = 168; +const CHART_H = 52; +const ACTIVE_LINE = "#60C679"; + +/** Inactive series: theme foreground so contrast tracks light/dark; opacity keeps it subdued vs text. */ +const INACTIVE_STROKE = "var(--foreground)"; + +const ACTIVE_GRADIENT_STOPS = [ + { offset: "0%", opacity: 0.52 }, + { offset: "50%", opacity: 0.2 }, + { offset: "100%", opacity: 0 }, +] as const; + +const INACTIVE_GRADIENT_STOPS = [ + { offset: "0%", opacity: 0.44 }, + { offset: "50%", opacity: 0.14 }, + { offset: "100%", opacity: 0 }, +] as const; + +/** Weekly commit sparkline; muted when there are no commits in the loaded range. */ +export function RepoCommitSparkline({ + scope, + owner, + repo, +}: { + scope: GitHubQueryScope; + owner: string; + repo: string; +}) { + const fillGradientId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const hasMounted = useHasMounted(); + const query = useQuery({ + ...githubRepoParticipationQueryOptions(scope, { owner, repo }), + enabled: hasMounted, + }); + + if (!hasMounted || query.isLoading) { + return ( +
+ ); + } + + const weekly = query.data?.weeklyCommits ?? []; + const hasActivity = + weekly.length > 0 && weekly.some((n) => typeof n === "number" && n > 0); + const strokeColor = hasActivity ? ACTIVE_LINE : INACTIVE_STROKE; + const strokeOpacity = hasActivity ? 1 : 0.5; + const gradientStops = hasActivity + ? ACTIVE_GRADIENT_STOPS + : INACTIVE_GRADIENT_STOPS; + + const chartData = + weekly.length > 0 + ? weekly.map((commits, i) => ({ i, commits })) + : Array.from({ length: 52 }, (_, i) => ({ i, commits: 0 })); + + const chartLabel = hasActivity + ? `Weekly commit activity for ${owner}/${repo}, last 52 weeks` + : `No commits in the last 52 weeks for ${owner}/${repo}`; + + return ( +
+ + + + {gradientStops.map((stop) => ( + + ))} + + + + +
+ ); +} diff --git a/apps/dashboard/src/components/repo/repository-card.tsx b/apps/dashboard/src/components/repo/repository-card.tsx deleted file mode 100644 index 6040764..0000000 --- a/apps/dashboard/src/components/repo/repository-card.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { GitForkIcon, StarIcon } from "@diffkit/icons"; -import { Link } from "@tanstack/react-router"; - -type RepositoryCardRepo = { - name: string; - owner: string; - description: string | null; - language: string | null; - stars: number; - forks?: number; - isPrivate: boolean; -}; - -const languageColors: Record = { - Astro: "#ff5a03", - CSS: "#563d7c", - Go: "#00add8", - HTML: "#e34c26", - JavaScript: "#f1e05a", - MDX: "#fcb32c", - Python: "#3572a5", - Rust: "#dea584", - Shell: "#89e051", - Swift: "#f05138", - TypeScript: "#3178c6", -}; - -export function RepositoryCard({ repo }: { repo: RepositoryCardRepo }) { - return ( - -
- - {repo.name} - - - {repo.isPrivate ? "Private" : "Public"} - -
- -
- {repo.description && ( -

- {repo.description} -

- )} -
- -
- {repo.language && ( - - - {repo.language} - - )} - {repo.stars > 0 && ( - - - {formatCount(repo.stars)} - - )} - {typeof repo.forks === "number" && repo.forks > 0 && ( - - - {formatCount(repo.forks)} - - )} -
- - ); -} - -function formatCount(count: number): string { - if (count >= 1000) { - return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; - } - return count.toString(); -} diff --git a/apps/dashboard/src/components/repo/repository-row.tsx b/apps/dashboard/src/components/repo/repository-row.tsx new file mode 100644 index 0000000..4add7a6 --- /dev/null +++ b/apps/dashboard/src/components/repo/repository-row.tsx @@ -0,0 +1,148 @@ +import { SquareLock02Icon, StarIcon, ViewIcon } from "@diffkit/icons"; +import { Link } from "@tanstack/react-router"; +import { Fragment, memo, type ReactNode } from "react"; +import { RepoCommitSparkline } from "#/components/repo/repo-commit-sparkline"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import type { GitHubQueryScope } from "#/lib/github.query"; +import type { UserRepoSummary } from "#/lib/github.types"; + +const languageColors: Record = { + Astro: "#ff5a03", + CSS: "#563d7c", + Go: "#00add8", + HTML: "#e34c26", + JavaScript: "#f1e05a", + MDX: "#fcb32c", + Python: "#3572a5", + Rust: "#dea584", + Shell: "#89e051", + Swift: "#f05138", + TypeScript: "#3178c6", +}; + +const visibilityAria: Record = { + public: "Public repository", + private: "Private repository", + internal: "Internal repository", +}; + +function VisibilityBadge({ + visibility, +}: { + visibility: UserRepoSummary["visibility"]; +}) { + const label = visibilityAria[visibility]; + const Icon = visibility === "public" ? ViewIcon : SquareLock02Icon; + + return ( + + + + ); +} + +export const RepositoryRow = memo(function RepositoryRow({ + repo, + scope, +}: { + repo: UserRepoSummary; + scope: GitHubQueryScope; +}) { + const metaEntries: { key: string; node: ReactNode }[] = []; + if (repo.language) { + metaEntries.push({ + key: "lang", + node: ( + + + {repo.language} + + ), + }); + } + if (repo.updatedAt) { + metaEntries.push({ + key: "time", + node: ( + {formatRelativeTime(repo.updatedAt)} + ), + }); + } + if (repo.stars > 0) { + metaEntries.push({ + key: "stars", + node: ( + + + {formatCount(repo.stars)} + + ), + }); + } + + return ( + +
+
+

+ {repo.fullName} +

+ +
+
+
+ {repo.description ? ( +

+ {repo.description} +

+ ) : ( + + )} +
+ {metaEntries.length > 0 ? ( +
+ {metaEntries.map((entry, index) => ( + + {index > 0 ? ( + · + ) : null} + {entry.node} + + ))} +
+ ) : ( +
+ +
+ )} +
+ +
+ + ); +}); + +function formatCount(count: number): string { + if (count >= 1000) { + return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; + } + return count.toString(); +} diff --git a/apps/dashboard/src/lib/github-cache-policy.ts b/apps/dashboard/src/lib/github-cache-policy.ts index 882616f..1f63cfc 100644 --- a/apps/dashboard/src/lib/github-cache-policy.ts +++ b/apps/dashboard/src/lib/github-cache-policy.ts @@ -40,6 +40,10 @@ export const githubCachePolicy = { staleTimeMs: 30 * 60 * 1000, gcTimeMs: 24 * 60 * 60 * 1000, }, + repoParticipation: { + staleTimeMs: 60 * 60 * 1000, + gcTimeMs: 24 * 60 * 60 * 1000, + }, installationAccess: { staleTimeMs: 30 * 60 * 1000, gcTimeMs: 24 * 60 * 60 * 1000, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 77c4fa8..d0ae848 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -42,6 +42,7 @@ import type { RepoCollaborator, RepoContributorsResult, RepoOverview, + RepoParticipationStats, RepositoryRef, RepoTreeEntry, RequestedTeam, @@ -390,6 +391,15 @@ type GitHubGraphQLIssuePageResponse = { type AuthenticatedUserRepo = Awaited< ReturnType >["data"][number]; + +function mapUserRepoVisibility( + repo: AuthenticatedUserRepo, +): "public" | "private" | "internal" { + if (repo.visibility === "internal") return "internal"; + if (repo.visibility === "private" || repo.private) return "private"; + return "public"; +} + type RepoPullDetail = Awaited< ReturnType >["data"]; @@ -5140,41 +5150,41 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( } const [repos, accessIndex] = await Promise.all([ - getCachedPaginatedGitHubRequest< - AuthenticatedUserRepo, - UserRepoSummary[] - >({ - context, - resource: "repos.list", - params: { sort: "updated", perPage: 100 }, - freshForMs: githubCachePolicy.reposList.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.installationAccess], - namespaceKeys: ["repos.list"], - cacheMode: "split", - pageSize: 100, - request: (page) => - context.octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 100, - page, - }), - mapData: (repos) => - repos.map( - (repo: AuthenticatedUserRepo): UserRepoSummary => ({ - id: repo.id, - name: repo.name, - fullName: repo.full_name, - description: repo.description, - stars: repo.stargazers_count, - language: repo.language, - updatedAt: repo.updated_at, - createdAt: repo.created_at, - isPrivate: repo.private, - url: repo.html_url, - owner: repo.owner.login, + getCachedPaginatedGitHubRequest( + { + context, + resource: "repos.list", + params: { sort: "updated", perPage: 100 }, + freshForMs: githubCachePolicy.reposList.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.installationAccess], + namespaceKeys: ["repos.list"], + cacheMode: "split", + pageSize: 100, + request: (page) => + context.octokit.rest.repos.listForAuthenticatedUser({ + sort: "updated", + per_page: 100, + page, }), - ), - }), + mapData: (repos) => + repos.map( + (repo: AuthenticatedUserRepo): UserRepoSummary => ({ + id: repo.id, + name: repo.name, + fullName: repo.full_name, + description: repo.description, + stars: repo.stargazers_count, + language: repo.language, + updatedAt: repo.updated_at, + createdAt: repo.created_at, + isPrivate: repo.private, + visibility: mapUserRepoVisibility(repo), + url: repo.html_url, + owner: repo.owner.login, + }), + ), + }, + ), getInstallationAccessIndex(context), ]); @@ -7619,6 +7629,90 @@ type RepoOverviewInput = { repo: string; }; +export const getRepoParticipationStats = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) { + return { weeklyCommits: [] }; + } + + const repoMetaKey = githubRevalidationSignalKeys.repoMeta(data); + const repoCodeKey = githubRevalidationSignalKeys.repoCode(data); + + return getOrRevalidateGitHubResource({ + userId: context.session.user.id, + resource: "repos.participationStats", + params: data, + freshForMs: githubCachePolicy.repoParticipation.staleTimeMs, + signalKeys: [repoMetaKey, repoCodeKey], + namespaceKeys: [repoMetaKey, repoCodeKey], + cacheMode: "split", + fetcher: async (conditionals) => { + try { + const response = + await context.octokit.rest.repos.getParticipationStats({ + owner: data.owner, + repo: data.repo, + headers: buildConditionalHeaders(conditionals), + }); + + const weeklyCommits = Array.isArray(response.data?.all) + ? response.data.all + : []; + + return { + kind: "success", + data: { weeklyCommits }, + metadata: createGitHubResponseMetadata( + response.status, + normalizeResponseHeaders(response.headers), + ), + }; + } catch (error) { + if ( + error instanceof RequestError && + error.status === 304 && + error.response?.headers + ) { + return { + kind: "not-modified", + metadata: createGitHubResponseMetadata( + 304, + normalizeResponseHeaders( + error.response.headers as Record, + ), + ), + }; + } + + if (error instanceof RequestError) { + if ( + error.status === 404 || + error.status === 403 || + error.status === 202 + ) { + return { + kind: "success", + data: { weeklyCommits: [] }, + metadata: createGitHubResponseMetadata( + error.status, + error.response?.headers + ? normalizeResponseHeaders( + error.response.headers as Record, + ) + : {}, + ), + }; + } + } + + throw error; + } + }, + }); + }); + export const getRepoOverview = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 0500589..c3af806 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -29,6 +29,7 @@ import { getRepoFileContent, getRepoLabels, getRepoOverview, + getRepoParticipationStats, getRepoTree, getReviewThreadStatuses, getTimelineEventPage, @@ -214,6 +215,10 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string }, ) => ["github", scope.userId, "repo", "contributors", input] as const, + participation: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => ["github", scope.userId, "repo", "participation", input] as const, discussions: ( scope: GitHubQueryScope, input: { owner: string; repo: string }, @@ -638,6 +643,19 @@ export function githubRepoBranchesQueryOptions( }); } +export function githubRepoParticipationQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.participation(scope, input), + queryFn: () => getRepoParticipationStats({ data: input }), + staleTime: githubCachePolicy.repoParticipation.staleTimeMs, + gcTime: githubCachePolicy.repoParticipation.gcTimeMs, + meta: persistedMeta, + }); +} + export function githubRepoContributorsQueryOptions( scope: GitHubQueryScope, input: { owner: string; repo: string }, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index a110db8..aa06d4c 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -28,6 +28,11 @@ export type GitHubLabel = { description: string | null; }; +/** Weekly total commit counts; index `0` is oldest week, last index is most recent (GitHub participation API). */ +export type RepoParticipationStats = { + weeklyCommits: number[]; +}; + export type UserRepoSummary = { id: number; name: string; @@ -38,6 +43,8 @@ export type UserRepoSummary = { updatedAt: string | null; createdAt: string | null; isPrivate: boolean; + /** GitHub visibility (`internal` on Enterprise); derived when the API omits `visibility`. */ + visibility: "public" | "private" | "internal"; url: string; owner: string; }; diff --git a/apps/dashboard/src/routes/_protected/repos.tsx b/apps/dashboard/src/routes/_protected/repos.tsx index bdd84b8..e943f13 100644 --- a/apps/dashboard/src/routes/_protected/repos.tsx +++ b/apps/dashboard/src/routes/_protected/repos.tsx @@ -17,7 +17,7 @@ import { useListFilters, } from "#/components/filters"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; -import { RepositoryCard } from "#/components/repo/repository-card"; +import { RepositoryRow } from "#/components/repo/repository-row"; import { githubUserReposQueryOptions } from "#/lib/github.query"; import type { UserRepoSummary } from "#/lib/github.types"; import { buildSeo, formatPageTitle } from "#/lib/seo"; @@ -179,17 +179,25 @@ function RepositoriesPage() { {repos.length === 0 ? ( -
+

No repositories found. -

+

) : filteredRepos.length === 0 ? ( -
+

No repositories match these filters. -

+

) : ( -
+
{filteredRepos.map((repo) => ( - +
+ +
))}
)} diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index b9a01d7..1ae8236 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -62,7 +62,7 @@ export { Settings01Icon as SettingsIcon, SidebarLeftIcon as PanelLeftIcon, SourceCodeIcon as CodeIcon, - StarIcon, + SquareLock02Icon, Sun01Icon as SunIcon, Tick02Icon as CheckIcon, Tick02Icon as TickIcon, @@ -76,3 +76,4 @@ export { ArchiveDownIcon } from "./archive-down-icon"; export { GitHubLogo, GitHubWordmarkLogo, XLogo } from "./brand-logos"; export { PenIcon } from "./pen-icon"; export { SeparatorHorizontalIcon } from "./separator-horizontal-icon"; +export { StarIcon } from "./star-icon"; diff --git a/packages/icons/src/star-icon.tsx b/packages/icons/src/star-icon.tsx new file mode 100644 index 0000000..0d2249e --- /dev/null +++ b/packages/icons/src/star-icon.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from "react"; + +export function StarIcon( + props: SVGProps & { size?: number; strokeWidth?: number } +) { + const { size = 24, width, height, strokeWidth = 2, ...rest } = props; + return ( + + Star + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7cb028..e8d4bc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: react-dropzone: specifier: ^15.0.0 version: 15.0.0(react@19.2.4) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) tailwindcss: specifier: ^4.1.18 version: 4.2.2 @@ -2344,6 +2347,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-beta.40': resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} @@ -2583,6 +2597,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -2964,6 +2981,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -3005,6 +3049,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -3354,6 +3401,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3373,6 +3464,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -3559,6 +3653,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -3763,9 +3860,19 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -4333,6 +4440,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -4402,6 +4521,22 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -4434,6 +4569,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -4808,6 +4946,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6703,6 +6844,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.40': {} '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -6917,6 +7070,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -7377,6 +7532,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -7419,6 +7598,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': @@ -7744,6 +7925,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-urls@7.0.0(@noble/hashes@2.0.1): dependencies: whatwg-mimetype: 5.0.0 @@ -7759,6 +7978,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: @@ -7844,6 +8065,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-toolkit@1.45.1: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -8175,8 +8398,14 @@ snapshots: dependencies: safer-buffer: 2.1.2 + immer@10.2.0: {} + + immer@11.1.4: {} + inline-style-parser@0.2.7: {} + internmap@2.0.3: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -8920,6 +9149,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -9002,6 +9240,32 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 17.0.2 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -9058,6 +9322,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-pkg-maps@1.0.0: {} restore-cursor@5.1.0: @@ -9467,6 +9733,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 From ff5584c77b8b7e65222860a69e7c8b098f2da118 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 00:21:38 -0400 Subject: [PATCH 3/5] repos hub: server filtering, prefetch parity, unified Spinner loaders - Full install-filtered hub + filter/sort; repo-list-page + repos-hub-filter - Repos route: validateSearch page, prefetch aligned with cookie+page, debounced search - Profile owner: tab/repos hub, load more, layout tweaks - Replace LoaderCircleIcon with @diffkit/ui Spinner in dashboard loading + setup - Nav/palette: ArchiveIcon for repositories; tabs tweak --- .../layouts/dashboard-content-loading.tsx | 7 +- .../layouts/dashboard-mobile-nav.tsx | 4 +- .../components/layouts/dashboard-topbar.tsx | 4 +- .../src/lib/command-palette/registry.ts | 4 +- apps/dashboard/src/lib/github.functions.ts | 228 ++++++++++++----- apps/dashboard/src/lib/github.query.ts | 33 +++ apps/dashboard/src/lib/repo-list-page.ts | 29 +++ apps/dashboard/src/lib/repos-hub-filter.ts | 110 ++++++++ apps/dashboard/src/lib/use-debounced-value.ts | 10 + .../src/routes/_protected/$owner/index.tsx | 242 ++++++++++++++---- .../dashboard/src/routes/_protected/repos.tsx | 235 +++++++++++------ apps/dashboard/src/routes/setup.tsx | 7 +- packages/ui/src/components/tabs.tsx | 5 +- 13 files changed, 700 insertions(+), 218 deletions(-) create mode 100644 apps/dashboard/src/lib/repo-list-page.ts create mode 100644 apps/dashboard/src/lib/repos-hub-filter.ts create mode 100644 apps/dashboard/src/lib/use-debounced-value.ts diff --git a/apps/dashboard/src/components/layouts/dashboard-content-loading.tsx b/apps/dashboard/src/components/layouts/dashboard-content-loading.tsx index 0134603..d72ccf8 100644 --- a/apps/dashboard/src/components/layouts/dashboard-content-loading.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-content-loading.tsx @@ -1,12 +1,9 @@ -import { LoaderCircleIcon } from "@diffkit/icons"; +import { Spinner } from "@diffkit/ui/components/spinner"; export function DashboardContentLoading() { return (
- +
); } diff --git a/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx b/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx index 5ff9781..73a8c0a 100644 --- a/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx @@ -1,5 +1,5 @@ import { - FolderLibraryIcon, + ArchiveIcon, GitPullRequestIcon, HomeIcon, InboxIcon, @@ -85,7 +85,7 @@ export function DashboardMobileNav({ const navItems: MobileNavItem[] = [ { to: "/", label: "Overview", icon: HomeIcon }, { to: "/inbox", label: "Inbox", icon: InboxIcon }, - { to: "/repos", label: "Repos", icon: FolderLibraryIcon }, + { to: "/repos", label: "Repos", icon: ArchiveIcon }, { to: "/pulls", label: "Pulls", diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index 50e2f86..b9c1df1 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -1,7 +1,7 @@ import { + ArchiveIcon, BugIcon, ExternalLinkIcon, - FolderLibraryIcon, GitPullRequestIcon, HomeIcon, InboxIcon, @@ -279,7 +279,7 @@ export function DashboardTopbar({ - + Repositories diff --git a/apps/dashboard/src/lib/command-palette/registry.ts b/apps/dashboard/src/lib/command-palette/registry.ts index a92151b..89fb82b 100644 --- a/apps/dashboard/src/lib/command-palette/registry.ts +++ b/apps/dashboard/src/lib/command-palette/registry.ts @@ -1,7 +1,7 @@ import { + ArchiveIcon, DashboardIcon, ExternalLinkIcon, - FolderLibraryIcon, GitPullRequestIcon, IssuesIcon, ReviewsIcon, @@ -52,7 +52,7 @@ registerCommands([ id: "nav:repos", label: "Go to Repositories", group: "Pages", - icon: FolderLibraryIcon, + icon: ArchiveIcon, keywords: ["repos", "code"], shortcut: ["G", "O"], action: { type: "navigate", to: "/repos" }, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index d0ae848..06f61d7 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -77,6 +77,11 @@ import { } from "./github-cache"; import { githubCachePolicy } from "./github-cache-policy"; import { githubRevalidationSignalKeys } from "./github-revalidation"; +import { + filterUserRepoSummaries, + type ReposHubInput, + type ReposHubResult, +} from "./repos-hub-filter"; type GitHubClient = OctokitType; type AuthSession = { @@ -391,13 +396,33 @@ type GitHubGraphQLIssuePageResponse = { type AuthenticatedUserRepo = Awaited< ReturnType >["data"][number]; +type ListForUserRepo = Awaited< + ReturnType +>["data"][number]; -function mapUserRepoVisibility( - repo: AuthenticatedUserRepo, -): "public" | "private" | "internal" { - if (repo.visibility === "internal") return "internal"; - if (repo.visibility === "private" || repo.private) return "private"; - return "public"; +function mapGithubRestRepoToUserRepoSummary( + repo: AuthenticatedUserRepo | ListForUserRepo, +): UserRepoSummary { + const visibility: UserRepoSummary["visibility"] = + repo.visibility === "internal" + ? "internal" + : repo.visibility === "private" || repo.private + ? "private" + : "public"; + return { + id: repo.id!, + name: repo.name ?? "", + fullName: repo.full_name ?? "", + description: repo.description ?? null, + stars: repo.stargazers_count ?? 0, + language: repo.language ?? null, + updatedAt: repo.updated_at ?? null, + createdAt: repo.created_at ?? null, + isPrivate: Boolean(repo.private), + visibility, + url: repo.html_url ?? "", + owner: repo.owner.login ?? "", + }; } type RepoPullDetail = Awaited< @@ -5142,64 +5167,45 @@ export const refreshInstallationAccess = createServerFn({ return { ok: true }; }); -export const getUserRepos = createServerFn({ method: "GET" }).handler( - async (): Promise => { - const context = await getGitHubContext(); - if (!context) { - return []; - } - - const [repos, accessIndex] = await Promise.all([ - getCachedPaginatedGitHubRequest( - { - context, - resource: "repos.list", - params: { sort: "updated", perPage: 100 }, - freshForMs: githubCachePolicy.reposList.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.installationAccess], - namespaceKeys: ["repos.list"], - cacheMode: "split", - pageSize: 100, - request: (page) => - context.octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 100, - page, - }), - mapData: (repos) => - repos.map( - (repo: AuthenticatedUserRepo): UserRepoSummary => ({ - id: repo.id, - name: repo.name, - fullName: repo.full_name, - description: repo.description, - stars: repo.stargazers_count, - language: repo.language, - updatedAt: repo.updated_at, - createdAt: repo.created_at, - isPrivate: repo.private, - visibility: mapUserRepoVisibility(repo), - url: repo.html_url, - owner: repo.owner.login, - }), - ), - }, - ), - getInstallationAccessIndex(context), - ]); +async function fetchInstallationFilteredAuthenticatedRepos( + context: GitHubContext, +): Promise { + const [repos, accessIndex] = await Promise.all([ + getCachedPaginatedGitHubRequest({ + context, + resource: "repos.list", + params: { sort: "updated", perPage: 100 }, + freshForMs: githubCachePolicy.reposList.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.installationAccess], + namespaceKeys: ["repos.list"], + cacheMode: "split", + pageSize: 100, + request: (page) => + context.octokit.rest.repos.listForAuthenticatedUser({ + sort: "updated", + per_page: 100, + page, + }), + mapData: (items) => items.map(mapGithubRestRepoToUserRepoSummary), + }), + getInstallationAccessIndex(context), + ]); - const filtered = repos.filter((repo) => - isRepoVisibleWithInstallationAccess( - accessIndex, - repo.owner, - repo.name, - repo.isPrivate, - ), - ); + const filtered = repos.filter((repo) => + isRepoVisibleWithInstallationAccess( + accessIndex, + repo.owner, + repo.name, + repo.isPrivate, + ), + ); - const removedCount = repos.length - filtered.length; - if (removedCount > 0) { - debug("installation-access", "getUserRepos filtered", { + const removedCount = repos.length - filtered.length; + if (removedCount > 0) { + debug( + "installation-access", + "fetchInstallationFilteredAuthenticatedRepos", + { total: repos.length, kept: filtered.length, removed: removedCount, @@ -5214,13 +5220,105 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( ), ) .map((repo) => repo.fullName), - }); - } + }, + ); + } - return filtered; + return filtered; +} + +async function fetchPublicReposForUser( + context: GitHubContext, + username: string, +): Promise { + return getCachedPaginatedGitHubRequest({ + context, + resource: "repos.listForUser", + params: { username, sort: "updated", perPage: 100 }, + freshForMs: githubCachePolicy.reposList.staleTimeMs, + namespaceKeys: ["repos.listForUser"], + cacheMode: "split", + pageSize: 100, + request: (page, signal) => + context.octokit.rest.repos.listForUser({ + username, + sort: "updated", + per_page: 100, + page, + request: { signal }, + }), + mapData: (items) => items.map(mapGithubRestRepoToUserRepoSummary), + }); +} + +export const getUserRepos = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + return fetchInstallationFilteredAuthenticatedRepos(context); }, ); +export const getReposHub = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return { + totals: { all: 0, public: 0, private: 0 }, + matchingCount: 0, + repos: [], + }; + } + + const limit = Math.min(Math.max(1, data.limit), 10_000); + const all = await fetchInstallationFilteredAuthenticatedRepos(context); + const totals = { + all: all.length, + public: all.filter((r) => !r.isPrivate).length, + private: all.filter((r) => r.isPrivate).length, + }; + const filtered = filterUserRepoSummaries(all, { + searchQuery: data.searchQuery, + visibility: data.visibility, + sortId: data.sortId, + }); + + return { + totals, + matchingCount: filtered.length, + repos: filtered.slice(0, limit), + }; + }); + +export const getProfileRepos = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + const viewer = await getViewer(context); + const isOwnProfile = + viewer.login.toLowerCase() === data.username.toLowerCase(); + + if (isOwnProfile) { + return fetchInstallationFilteredAuthenticatedRepos(context); + } + + try { + return await fetchPublicReposForUser(context, data.username); + } catch (error) { + if (error instanceof RequestError && error.status === 404) { + return []; + } + throw error; + } + }); + export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index c3af806..4439ad8 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -13,6 +13,7 @@ import { getMyPulls, getNotifications, getOrgTeams, + getProfileRepos, getPullComments, getPullFileSummaries, getPullFiles, @@ -30,6 +31,7 @@ import { getRepoLabels, getRepoOverview, getRepoParticipationStats, + getReposHub, getRepoTree, getReviewThreadStatuses, getTimelineEventPage, @@ -43,6 +45,7 @@ import { } from "./github.functions"; import { githubCachePolicy } from "./github-cache-policy"; import { ensureDefinedQueryData } from "./query-data"; +import type { ReposHubInput } from "./repos-hub-filter"; type RepoState = "all" | "closed" | "open"; type PullSort = "created" | "long-running" | "popularity" | "updated"; @@ -127,6 +130,8 @@ export const githubQueryKeys = { repos: { list: (scope: GitHubQueryScope) => ["github", scope.userId, "repos", "list"] as const, + hub: (scope: GitHubQueryScope, input: ReposHubInput) => + ["github", scope.userId, "repos", "hub", input] as const, }, search: { commandPalette: ( @@ -180,6 +185,8 @@ export const githubQueryKeys = { ) => ["github", scope.userId, "timelineEventPage", input] as const, profile: (scope: GitHubQueryScope, username: string) => ["github", scope.userId, "profile", username] as const, + profileRepos: (scope: GitHubQueryScope, username: string) => + ["github", scope.userId, "profileRepos", username] as const, contributions: (scope: GitHubQueryScope, username: string) => ["github", scope.userId, "contributions", username] as const, pinnedRepos: (scope: GitHubQueryScope, username: string) => @@ -266,6 +273,32 @@ export function githubUserReposQueryOptions(scope: GitHubQueryScope) { }); } +export function githubReposHubQueryOptions( + scope: GitHubQueryScope, + input: ReposHubInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.repos.hub(scope, input), + queryFn: () => getReposHub({ data: input }), + staleTime: githubCachePolicy.reposList.staleTimeMs, + gcTime: githubCachePolicy.reposList.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubProfileReposQueryOptions( + scope: GitHubQueryScope, + username: string, +) { + return queryOptions({ + queryKey: githubQueryKeys.profileRepos(scope, username), + queryFn: () => getProfileRepos({ data: { username } }), + staleTime: githubCachePolicy.reposList.staleTimeMs, + gcTime: githubCachePolicy.reposList.gcTimeMs, + meta: persistedMeta, + }); +} + export function githubCommandPaletteSearchQueryOptions( scope: GitHubQueryScope, input: CommandPaletteSearchInput, diff --git a/apps/dashboard/src/lib/repo-list-page.ts b/apps/dashboard/src/lib/repo-list-page.ts new file mode 100644 index 0000000..84ac27d --- /dev/null +++ b/apps/dashboard/src/lib/repo-list-page.ts @@ -0,0 +1,29 @@ +import { parseAsInteger } from "nuqs"; + +/** Match GitHub’s default repo list page size */ +export const REPO_LIST_PAGE_SIZE = 30; + +export const repoListPageQueryParser = parseAsInteger + .withDefault(1) + .withOptions({ history: "push" }); + +export function maxRepoListPage(itemCount: number): number { + return Math.max(1, Math.ceil(itemCount / REPO_LIST_PAGE_SIZE)); +} + +export function safeRepoListPage(page: number, itemCount: number): number { + const max = maxRepoListPage(itemCount); + return Math.min(Math.max(1, page), max); +} + +/** Cumulative slice for “Load more”: page 1 → first chunk, page 2 → first two chunks, etc. */ +export function sliceReposForPage(items: T[], page: number): T[] { + const safe = safeRepoListPage(page, items.length); + const end = Math.min(items.length, safe * REPO_LIST_PAGE_SIZE); + return items.slice(0, end); +} + +export function repoListHasNextPage(page: number, itemCount: number): boolean { + const safe = safeRepoListPage(page, itemCount); + return safe * REPO_LIST_PAGE_SIZE < itemCount; +} diff --git a/apps/dashboard/src/lib/repos-hub-filter.ts b/apps/dashboard/src/lib/repos-hub-filter.ts new file mode 100644 index 0000000..3b28d03 --- /dev/null +++ b/apps/dashboard/src/lib/repos-hub-filter.ts @@ -0,0 +1,110 @@ +import type { SerializedFilterStore } from "#/components/filters/filter-cookie"; +import { REPO_LIST_PAGE_SIZE } from "#/lib/repo-list-page"; +import type { UserRepoSummary } from "./github.types"; + +const REPOS_FILTER_PAGE_ID = "repos"; +const DEFAULT_REPOS_SORT = "updated"; +const VALID_REPOS_SORT_IDS = new Set([ + "updated", + "created", + "created-asc", + "title", +]); + +/** Matches client hub query key: cookie filters + URL page (for loader prefetch). */ +export function buildReposHubPrefetchInput( + filterStore: SerializedFilterStore, + urlPage: number, +): ReposHubInput { + const raw = filterStore[REPOS_FILTER_PAGE_ID]; + const searchQuery = + raw && typeof raw === "object" && typeof raw.searchQuery === "string" + ? raw.searchQuery + : ""; + let sortId = + raw && typeof raw === "object" && typeof raw.sortId === "string" + ? raw.sortId + : DEFAULT_REPOS_SORT; + if (!VALID_REPOS_SORT_IDS.has(sortId)) sortId = DEFAULT_REPOS_SORT; + + const visibility: string[] = []; + if (raw && typeof raw === "object" && Array.isArray(raw.activeFilters)) { + for (const f of raw.activeFilters) { + if ( + typeof f === "object" && + f !== null && + (f as { fieldId?: string }).fieldId === "visibility" && + Array.isArray((f as { values: unknown }).values) + ) { + for (const v of (f as { values: string[] }).values) { + if (typeof v === "string") visibility.push(v); + } + } + } + } + visibility.sort(); + + const page = + Number.isFinite(urlPage) && urlPage > 0 + ? Math.min(Math.floor(urlPage), 10_000) + : 1; + + return { + searchQuery, + visibility, + sortId, + limit: page * REPO_LIST_PAGE_SIZE, + }; +} + +export type ReposHubResult = { + totals: { all: number; public: number; private: number }; + matchingCount: number; + repos: UserRepoSummary[]; +}; + +export type ReposHubInput = { + searchQuery: string; + /** Selected visibility pill values: `"public"` and/or `"private"`; empty = no visibility filter */ + visibility: string[]; + sortId: string; + /** Cumulative max rows to return after filter + sort */ + limit: number; +}; + +function getTime(value: string | null): number { + return value ? Date.parse(value) || 0 : 0; +} + +const sortCompare: Record< + string, + (a: UserRepoSummary, b: UserRepoSummary) => number +> = { + updated: (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt), + created: (a, b) => getTime(b.createdAt) - getTime(a.createdAt), + "created-asc": (a, b) => getTime(a.createdAt) - getTime(b.createdAt), + title: (a, b) => a.name.localeCompare(b.name), +}; + +export function filterUserRepoSummaries( + repos: UserRepoSummary[], + input: Omit, +): UserRepoSummary[] { + let result = repos; + const q = input.searchQuery.toLowerCase().trim(); + if (q) { + result = result.filter( + (r) => + r.name.toLowerCase().includes(q) || + r.fullName.toLowerCase().includes(q) || + r.owner.toLowerCase().includes(q) || + (r.description?.toLowerCase().includes(q) ?? false), + ); + } + if (input.visibility.length > 0) { + const vis = new Set(input.visibility); + result = result.filter((r) => vis.has(r.isPrivate ? "private" : "public")); + } + const sortFn = sortCompare[input.sortId] ?? sortCompare.updated; + return [...result].sort(sortFn); +} diff --git a/apps/dashboard/src/lib/use-debounced-value.ts b/apps/dashboard/src/lib/use-debounced-value.ts new file mode 100644 index 0000000..05c56d0 --- /dev/null +++ b/apps/dashboard/src/lib/use-debounced-value.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(t); + }, [value, delayMs]); + return debounced; +} diff --git a/apps/dashboard/src/routes/_protected/$owner/index.tsx b/apps/dashboard/src/routes/_protected/$owner/index.tsx index ea529b5..8ab0d21 100644 --- a/apps/dashboard/src/routes/_protected/$owner/index.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/index.tsx @@ -14,12 +14,17 @@ import { import { Button } from "@diffkit/ui/components/button"; import { Skeleton } from "@diffkit/ui/components/skeleton"; import { Spinner } from "@diffkit/ui/components/spinner"; +import { Tabs, TabsList, TabsTrigger } from "@diffkit/ui/components/tabs"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; +import { useEffect, useMemo, useRef } from "react"; import { ContributionGraph } from "#/components/profile/contribution-graph"; import { PinnedRepoCard } from "#/components/profile/pinned-repo-card"; import { UserActivityFeed } from "#/components/profile/user-activity-feed"; +import { RepositoryRow } from "#/components/repo/repository-row"; import { + githubProfileReposQueryOptions, githubUserActivityQueryOptions, githubUserContributionsQueryOptions, githubUserPinnedReposQueryOptions, @@ -27,6 +32,12 @@ import { githubViewerQueryOptions, } from "#/lib/github.query"; import type { GitHubUserProfile } from "#/lib/github.types"; +import { + repoListHasNextPage, + repoListPageQueryParser, + safeRepoListPage, + sliceReposForPage, +} from "#/lib/repo-list-page"; import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; @@ -55,11 +66,33 @@ export const Route = createFileRoute("/_protected/$owner/")({ component: ProfilePage, }); +/** GitHub-style profile tabs: `?tab=repositories` */ +const profileTabSearchParser = parseAsStringLiteral([ + "repositories", +] as const).withOptions({ history: "push" }); + function ProfilePage() { const { owner } = Route.useParams(); const { user } = Route.useRouteContext(); const scope = { userId: user.id }; const hasMounted = useHasMounted(); + const [tabSearchParam, setTabSearchParam] = useQueryState( + "tab", + profileTabSearchParser, + ); + const [reposPage, setReposPage] = useQueryState( + "page", + repoListPageQueryParser, + ); + const profileTab = tabSearchParam === "repositories" ? "repos" : "overview"; + + const prevOwnerRef = useRef(owner); + useEffect(() => { + if (prevOwnerRef.current !== owner) { + prevOwnerRef.current = owner; + void setReposPage(null); + } + }, [owner, setReposPage]); // Server-side: available immediately from loader const profileQuery = useQuery(githubUserProfileQueryOptions(scope, owner)); @@ -81,12 +114,36 @@ function ProfilePage() { const activityQuery = useInfiniteQuery({ ...githubUserActivityQueryOptions(scope, owner, isOwnProfile), - enabled: hasMounted && viewerQuery.data !== undefined, + enabled: + hasMounted && viewerQuery.data !== undefined && profileTab === "overview", }); const activity = activityQuery.data?.pages.flat(); + const profileReposQuery = useQuery({ + ...githubProfileReposQueryOptions(scope, owner), + enabled: hasMounted && profileTab === "repos", + }); + + const profileRepos = + profileTab === "repos" ? (profileReposQuery.data ?? []) : []; + const safeReposPage = safeRepoListPage(reposPage ?? 1, profileRepos.length); + useEffect(() => { + if (profileTab !== "repos") return; + if ((reposPage ?? 1) !== safeReposPage) { + void setReposPage(safeReposPage === 1 ? null : safeReposPage); + } + }, [profileTab, reposPage, safeReposPage, setReposPage]); + + const paginatedProfileRepos = useMemo( + () => sliceReposForPage(profileRepos, safeReposPage), + [profileRepos, safeReposPage], + ); + if (profileQuery.error) throw profileQuery.error; if (profileQuery.data === null) throw new Error("Not found"); + if (profileTab === "repos" && profileReposQuery.error) { + throw profileReposQuery.error; + } return (
@@ -141,21 +198,50 @@ function ProfilePage() { {/* User info */} {profile ? (
-
-

- {profile.name ?? profile.login} -

-
- @{profile.login} - · - - Joined{" "} - {new Date(profile.createdAt).toLocaleDateString("en-US", { - month: "long", - year: "numeric", - })} - +
+
+

+ {profile.name ?? profile.login} +

+
+ @{profile.login} + · + + Joined{" "} + {new Date(profile.createdAt).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + })} + +
+ { + void setTabSearchParam( + value === "repos" ? "repositories" : null, + ); + if (value !== "repos") { + void setReposPage(null); + } + }} + className="w-full sm:w-auto sm:shrink-0" + > + + + Overview + + + Repositories + + +
{profile.bio && ( @@ -195,46 +281,98 @@ function ProfilePage() {
- {/* Pinned Repos */} - {pinnedRepos && pinnedRepos.length > 0 && ( -
-

- Pinned -

-
- {pinnedRepos.map((repo) => ( - - ))} -
-
- )} + {profileTab === "overview" ? ( + <> + {/* Pinned Repos */} + {pinnedRepos && pinnedRepos.length > 0 && ( +
+

+ Pinned +

+
+ {pinnedRepos.map((repo) => ( + + ))} +
+
+ )} - {/* Activity */} - {activity && activity.length > 0 && ( -
-
-

Recent activity

- - {activity.length} - -
- - {activityQuery.hasNextPage && ( - )} - Load more - +
)} + + ) : ( +
+
+ {profileReposQuery.isLoading ? ( +
+ +
+ ) : !profileReposQuery.data || + profileReposQuery.data.length === 0 ? ( +

+ No repositories to show. +

+ ) : ( + <> +
+ {paginatedProfileRepos.map((repo) => ( +
+ +
+ ))} +
+ {repoListHasNextPage( + safeReposPage, + profileRepos.length, + ) ? ( + + ) : null} + + )} +
)}
diff --git a/apps/dashboard/src/routes/_protected/repos.tsx b/apps/dashboard/src/routes/_protected/repos.tsx index e943f13..b843475 100644 --- a/apps/dashboard/src/routes/_protected/repos.tsx +++ b/apps/dashboard/src/routes/_protected/repos.tsx @@ -1,14 +1,16 @@ import { + ArchiveIcon, + ChevronDownIcon, FilterIcon, - FolderLibraryIcon, LockIcon, ViewIcon, } from "@diffkit/icons"; -import { useQuery } from "@tanstack/react-query"; +import { Button } from "@diffkit/ui/components/button"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { createElement, useMemo } from "react"; +import { useQueryState } from "nuqs"; +import { createElement, useEffect, useMemo, useRef } from "react"; import { - applyFilters, type FilterableItem, FilterBar, type FilterDefinition, @@ -18,17 +20,49 @@ import { } from "#/components/filters"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { RepositoryRow } from "#/components/repo/repository-row"; -import { githubUserReposQueryOptions } from "#/lib/github.query"; +import { + githubReposHubQueryOptions, + githubUserReposQueryOptions, +} from "#/lib/github.query"; import type { UserRepoSummary } from "#/lib/github.types"; +import { + REPO_LIST_PAGE_SIZE, + repoListHasNextPage, + repoListPageQueryParser, + safeRepoListPage, +} from "#/lib/repo-list-page"; +import { buildReposHubPrefetchInput } from "#/lib/repos-hub-filter"; import { buildSeo, formatPageTitle } from "#/lib/seo"; -import { useHasMounted } from "#/lib/use-has-mounted"; +import { useDebouncedValue } from "#/lib/use-debounced-value"; export const Route = createFileRoute("/_protected/repos")({ ssr: false, - loader: async ({ context }) => { + validateSearch: (raw: Record): { page?: number } => { + const pageRaw = raw.page; + if ( + typeof pageRaw === "number" && + Number.isFinite(pageRaw) && + pageRaw > 0 + ) { + return { page: Math.floor(pageRaw) }; + } + if (typeof pageRaw === "string" && pageRaw.length > 0) { + const n = Number.parseInt(pageRaw, 10); + if (Number.isFinite(n) && n > 0) return { page: n }; + } + return {}; + }, + loader: async ({ context, location }) => { const scope = { userId: context.user.id }; - void context.queryClient.prefetchQuery(githubUserReposQueryOptions(scope)); const filterStore = await getFilterCookie(); + const search = location.search as { page?: number }; + const urlPage = + typeof search.page === "number" && search.page > 0 ? search.page : 1; + const hubPrefetchInput = buildReposHubPrefetchInput(filterStore, urlPage); + await context.queryClient.prefetchQuery( + githubReposHubQueryOptions(scope, hubPrefetchInput), + ); + await context.queryClient.prefetchQuery(githubUserReposQueryOptions(scope)); return { filterStore }; }, pendingComponent: DashboardContentLoading, @@ -52,32 +86,24 @@ const repositoryFilterDefs: FilterDefinition[] = [ id: "visibility", label: "Visibility", icon: FilterIcon, - extractOptions: (items) => { - const hasPublic = items.some((item) => !asRepo(item).isPrivate); - const hasPrivate = items.some((item) => asRepo(item).isPrivate); - return [ - hasPublic - ? { - value: "public", - label: "Public", - icon: createElement(ViewIcon, { - size: 14, - className: "text-muted-foreground", - }), - } - : null, - hasPrivate - ? { - value: "private", - label: "Private", - icon: createElement(LockIcon, { - size: 14, - className: "text-muted-foreground", - }), - } - : null, - ].filter((option) => option !== null); - }, + extractOptions: () => [ + { + value: "public", + label: "Public", + icon: createElement(ViewIcon, { + size: 14, + className: "text-muted-foreground", + }), + }, + { + value: "private", + label: "Private", + icon: createElement(LockIcon, { + size: 14, + className: "text-muted-foreground", + }), + }, + ], match: (item, values) => values.has(asRepo(item).isPrivate ? "private" : "public"), }, @@ -106,42 +132,83 @@ const repositorySortOptions: SortOption[] = [ }, ]; +const EMPTY_REPO_ITEMS: RepositoryFilterItem[] = []; + function RepositoriesPage() { const { filterStore } = Route.useLoaderData(); const { user } = Route.useRouteContext(); const scope = useMemo(() => ({ userId: user.id }), [user.id]); - const hasMounted = useHasMounted(); - const reposQuery = useQuery({ - ...githubUserReposQueryOptions(scope), - enabled: hasMounted, - }); + const [page, setPage] = useQueryState("page", repoListPageQueryParser); - const filterItems = useMemo( - () => reposQuery.data?.map(toRepositoryFilterItem) ?? [], - [reposQuery.data], - ); const filterState = useListFilters({ pageId: "repos", - items: filterItems, + items: EMPTY_REPO_ITEMS, filterDefs: repositoryFilterDefs, sortOptions: repositorySortOptions, defaultSortId: "updated", initialStore: filterStore, }); - const filteredRepos = useMemo( - () => applyFilters(filterItems, filterState).map((item) => item.repo), - [filterItems, filterState], + + const debouncedSearch = useDebouncedValue(filterState.searchQuery, 300); + + const visibilityValues = useMemo(() => { + const f = filterState.activeFilters.find((x) => x.fieldId === "visibility"); + return f ? [...f.values].sort() : []; + }, [filterState.activeFilters]); + + const limit = (page ?? 1) * REPO_LIST_PAGE_SIZE; + + const hubInput = useMemo( + () => ({ + searchQuery: debouncedSearch, + visibility: visibilityValues, + sortId: filterState.sortId, + limit, + }), + [debouncedSearch, visibilityValues, filterState.sortId, limit], ); - if (reposQuery.error) throw reposQuery.error; + const hubQuery = useQuery({ + ...githubReposHubQueryOptions(scope, hubInput), + placeholderData: keepPreviousData, + }); + + const reposFilterSignature = useMemo(() => { + const filterParts = filterState.activeFilters + .map((f) => `${f.fieldId}:${[...f.values].sort().join(",")}`) + .sort() + .join("|"); + return `${debouncedSearch}\0${filterState.sortId}\0${filterParts}`; + }, [debouncedSearch, filterState.sortId, filterState.activeFilters]); + + const prevFilterSignature = useRef(reposFilterSignature); + useEffect(() => { + if (prevFilterSignature.current !== reposFilterSignature) { + prevFilterSignature.current = reposFilterSignature; + void setPage(null); + } + }, [reposFilterSignature, setPage]); + + const matchingCount = hubQuery.data?.matchingCount ?? 0; + const safePage = safeRepoListPage(page ?? 1, matchingCount); - if (!reposQuery.data) { + useEffect(() => { + if (!hubQuery.isSuccess || hubQuery.data === undefined) return; + const safe = safeRepoListPage(page ?? 1, hubQuery.data.matchingCount); + if ((page ?? 1) !== safe) { + void setPage(safe === 1 ? null : safe); + } + }, [hubQuery.isSuccess, hubQuery.data, page, setPage]); + + if (hubQuery.error) throw hubQuery.error; + + if (!hubQuery.data && hubQuery.isPending) { return ; } - const repos = reposQuery.data; - const publicCount = repos.filter((repo) => !repo.isPrivate).length; - const privateCount = repos.length - publicCount; + const hub = hubQuery.data; + const totals = hub?.totals ?? { all: 0, public: 0, private: 0 }; + const displayedRepos = hub?.repos ?? []; return (
@@ -158,19 +225,19 @@ function RepositoriesPage() {
@@ -178,28 +245,44 @@ function RepositoriesPage() {
- {repos.length === 0 ? ( + {totals.all === 0 ? (

No repositories found.

- ) : filteredRepos.length === 0 ? ( + ) : matchingCount === 0 ? (

No repositories match these filters.

) : ( -
- {filteredRepos.map((repo) => ( -
+
+ {displayedRepos.map((repo) => ( +
+ +
+ ))} +
+ {repoListHasNextPage(safePage, matchingCount) ? ( +
- ))} -
+ + Load more + + ) : null} + )}
@@ -236,17 +319,3 @@ function asRepo(item: FilterableItem) { function getTime(value: unknown) { return typeof value === "string" ? Date.parse(value) || 0 : 0; } - -function toRepositoryFilterItem(repo: UserRepoSummary): RepositoryFilterItem { - return { - ...repo, - repo, - title: repo.name, - updatedAt: repo.updatedAt ?? "", - createdAt: repo.createdAt ?? "", - comments: 0, - author: null, - repository: { fullName: repo.fullName }, - state: repo.isPrivate ? "private" : "public", - }; -} diff --git a/apps/dashboard/src/routes/setup.tsx b/apps/dashboard/src/routes/setup.tsx index cae42e2..cdf4275 100644 --- a/apps/dashboard/src/routes/setup.tsx +++ b/apps/dashboard/src/routes/setup.tsx @@ -1,6 +1,6 @@ -import { LoaderCircleIcon } from "@diffkit/icons"; import { Button } from "@diffkit/ui/components/button"; import { Logo } from "@diffkit/ui/components/logo"; +import { Spinner } from "@diffkit/ui/components/spinner"; import { createFileRoute, Link, redirect } from "@tanstack/react-router"; import { getSession } from "#/lib/auth.functions"; import { getGitHubAppAccessState } from "#/lib/github.functions"; @@ -17,10 +17,7 @@ function SetupPageLoading() { return (
- +
); diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index d061f2f..512558d 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -26,7 +26,7 @@ function TabsList({ Date: Sat, 18 Apr 2026 00:23:00 -0400 Subject: [PATCH 4/5] fix: address CodeRabbit PR review (sparkline N+1, forks, 202, app token, shortcut) - Lazy-mount RepoCommitSparkline per row via IntersectionObserver - Map forks_count to UserRepoSummary; show fork count in repo row meta - Participation stats: retry on HTTP 202 before failing; do not cache empty as final 202 - appInstallationHasRepositoryAccess: list installation repos with app user Octokit - Repos dropdown: show G O shortcut like Profile/Settings --- .../components/layouts/dashboard-topbar.tsx | 1 + .../src/components/repo/repository-row.tsx | 85 +++++++++++-- apps/dashboard/src/lib/github.functions.ts | 118 +++++++++++------- apps/dashboard/src/lib/github.types.ts | 1 + 4 files changed, 150 insertions(+), 55 deletions(-) diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index b9c1df1..f967ee3 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -281,6 +281,7 @@ export function DashboardTopbar({ Repositories + diff --git a/apps/dashboard/src/components/repo/repository-row.tsx b/apps/dashboard/src/components/repo/repository-row.tsx index 4add7a6..749646d 100644 --- a/apps/dashboard/src/components/repo/repository-row.tsx +++ b/apps/dashboard/src/components/repo/repository-row.tsx @@ -1,6 +1,18 @@ -import { SquareLock02Icon, StarIcon, ViewIcon } from "@diffkit/icons"; +import { + GitForkIcon, + SquareLock02Icon, + StarIcon, + ViewIcon, +} from "@diffkit/icons"; import { Link } from "@tanstack/react-router"; -import { Fragment, memo, type ReactNode } from "react"; +import { + Fragment, + memo, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; import { RepoCommitSparkline } from "#/components/repo/repo-commit-sparkline"; import { formatRelativeTime } from "#/lib/format-relative-time"; import type { GitHubQueryScope } from "#/lib/github.query"; @@ -89,6 +101,17 @@ export const RepositoryRow = memo(function RepositoryRow({ ), }); } + if (repo.forks > 0) { + metaEntries.push({ + key: "forks", + node: ( + + + {formatCount(repo.forks)} + + ), + }); + } return (
)} -
- -
+ ); }); +function LazyRepoCommitSparkline({ + scope, + owner, + repo, +}: { + scope: GitHubQueryScope; + owner: string; + repo: string; +}) { + const containerRef = useRef(null); + const [showChart, setShowChart] = useState(false); + + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setShowChart(true); + observer.disconnect(); + } + }, + { rootMargin: "240px 0px", threshold: 0 }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+ {showChart ? ( + + ) : ( +
+ )} +
+ ); +} + function formatCount(count: number): string { if (count >= 1000) { return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 06f61d7..234d9e7 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -415,6 +415,7 @@ function mapGithubRestRepoToUserRepoSummary( fullName: repo.full_name ?? "", description: repo.description ?? null, stars: repo.stargazers_count ?? 0, + forks: repo.forks_count ?? 0, language: repo.language ?? null, updatedAt: repo.updated_at ?? null, createdAt: repo.created_at ?? null, @@ -2109,9 +2110,17 @@ async function appInstallationHasRepositoryAccess( } try { + const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); + const appUserOctokit = await getGitHubAppUserClientByUserId( + context.session.user.id, + ); + if (!appUserOctokit) { + return false; + } + const repositories = await listPaginatedGitHubItems({ request: (page) => - context.octokit.rest.apps.listInstallationReposForAuthenticatedUser({ + appUserOctokit.rest.apps.listInstallationReposForAuthenticatedUser({ installation_id: installation.id, page, per_page: 100, @@ -7747,66 +7756,83 @@ export const getRepoParticipationStats = createServerFn({ method: "GET" }) namespaceKeys: [repoMetaKey, repoCodeKey], cacheMode: "split", fetcher: async (conditionals) => { - try { - const response = - await context.octokit.rest.repos.getParticipationStats({ - owner: data.owner, - repo: data.repo, - headers: buildConditionalHeaders(conditionals), - }); + const maxAttempts = 6; + const retryDelayMs = 1_500; - const weeklyCommits = Array.isArray(response.data?.all) - ? response.data.all - : []; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const headers = buildConditionalHeaders( + attempt === 0 ? conditionals : { etag: null, lastModified: null }, + ); + + try { + const response = + await context.octokit.rest.repos.getParticipationStats({ + owner: data.owner, + repo: data.repo, + headers, + }); + + const weeklyCommits = Array.isArray(response.data?.all) + ? response.data.all + : []; - return { - kind: "success", - data: { weeklyCommits }, - metadata: createGitHubResponseMetadata( - response.status, - normalizeResponseHeaders(response.headers), - ), - }; - } catch (error) { - if ( - error instanceof RequestError && - error.status === 304 && - error.response?.headers - ) { return { - kind: "not-modified", + kind: "success", + data: { weeklyCommits }, metadata: createGitHubResponseMetadata( - 304, - normalizeResponseHeaders( - error.response.headers as Record, - ), + response.status, + normalizeResponseHeaders(response.headers), ), }; - } - - if (error instanceof RequestError) { + } catch (error) { if ( - error.status === 404 || - error.status === 403 || - error.status === 202 + error instanceof RequestError && + error.status === 304 && + error.response?.headers ) { return { - kind: "success", - data: { weeklyCommits: [] }, + kind: "not-modified", metadata: createGitHubResponseMetadata( - error.status, - error.response?.headers - ? normalizeResponseHeaders( - error.response.headers as Record, - ) - : {}, + 304, + normalizeResponseHeaders( + error.response.headers as Record, + ), ), }; } - } - throw error; + if (error instanceof RequestError && error.status === 202) { + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => + setTimeout(resolve, retryDelayMs), + ); + continue; + } + throw error; + } + + if (error instanceof RequestError) { + if (error.status === 404 || error.status === 403) { + return { + kind: "success", + data: { weeklyCommits: [] }, + metadata: createGitHubResponseMetadata( + error.status, + error.response?.headers + ? normalizeResponseHeaders( + error.response.headers as Record, + ) + : {}, + ), + }; + } + } + + throw error; + } } + + throw new Error("participation stats: exhausted 202 retries"); }, }); }); diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index aa06d4c..ac83f17 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -39,6 +39,7 @@ export type UserRepoSummary = { fullName: string; description: string | null; stars: number; + forks: number; language: string | null; updatedAt: string | null; createdAt: string | null; From 345f991f25eda924c022f8159749ecb6711c535b Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 00:24:19 -0400 Subject: [PATCH 5/5] fix: use app user Octokit for reviewer installation repo listing --- apps/dashboard/src/lib/github.functions.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 234d9e7..e7a55d7 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -2902,9 +2902,17 @@ async function installationHasRepositoryAccess( } try { + const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); + const appUserOctokit = await getGitHubAppUserClientByUserId( + context.session.user.id, + ); + if (!appUserOctokit) { + return false; + } + const repositories = await listPaginatedGitHubItems({ request: (page) => - context.octokit.rest.apps.listInstallationReposForAuthenticatedUser({ + appUserOctokit.rest.apps.listInstallationReposForAuthenticatedUser({ installation_id: installation.id as number, page, per_page: 100,