From 938059b437daf411376eb3247d8a50bca6531651 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 11 Apr 2026 12:51:27 -0400 Subject: [PATCH] Scope command palette search to user's own PRs and issues Replace global GitHub search with user-scoped queries using involves:${login}, reducing API calls from 4 to 2. Fix merged PRs showing closed icon by extracting pull_request.merged_at from search results. Search items now navigate to detail pages and cache results into pulls.mine/issues.mine for subsequent lookups. --- .../components/navigation/command-palette.tsx | 15 ++- .../lib/command-palette/use-command-items.ts | 96 +++++++++---------- apps/dashboard/src/lib/github.functions.ts | 92 +++--------------- apps/dashboard/src/lib/github.types.ts | 2 - 4 files changed, 74 insertions(+), 131 deletions(-) diff --git a/apps/dashboard/src/components/navigation/command-palette.tsx b/apps/dashboard/src/components/navigation/command-palette.tsx index 773f129..1a7b729 100644 --- a/apps/dashboard/src/components/navigation/command-palette.tsx +++ b/apps/dashboard/src/components/navigation/command-palette.tsx @@ -9,11 +9,12 @@ import { CommandShortcut, } from "@diffkit/ui/components/command"; import { cn } from "@diffkit/ui/lib/utils"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getRouteApi, useRouter } from "@tanstack/react-router"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { CommandItem, CommandItemMeta } from "#/lib/command-palette/types"; import { + cacheSearchResults, getCommandSearchItems, useCommandItems, } from "#/lib/command-palette/use-command-items"; @@ -26,7 +27,9 @@ const routeApi = getRouteApi("/_protected"); export function CommandPalette() { const { open, setOpen, close } = useCommandPalette(); const router = useRouter(); + const queryClient = useQueryClient(); const { user } = routeApi.useRouteContext(); + const scope = useMemo(() => ({ userId: user.id }), [user.id]); const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const trimmedDebouncedSearch = debouncedSearch.trim(); @@ -48,6 +51,14 @@ export function CommandPalette() { [items, searchItems], ); + const cachedSearchDataRef = useRef(githubSearchQuery.data); + useEffect(() => { + const data = githubSearchQuery.data; + if (!data || data === cachedSearchDataRef.current) return; + cachedSearchDataRef.current = data; + cacheSearchResults(queryClient, scope, data); + }, [githubSearchQuery.data, queryClient, scope]); + const groups = new Map(); for (const item of allItems) { const list = groups.get(item.group) ?? []; diff --git a/apps/dashboard/src/lib/command-palette/use-command-items.ts b/apps/dashboard/src/lib/command-palette/use-command-items.ts index 47d27a8..70789c3 100644 --- a/apps/dashboard/src/lib/command-palette/use-command-items.ts +++ b/apps/dashboard/src/lib/command-palette/use-command-items.ts @@ -5,15 +5,14 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, IssuesIcon, - UserAddIcon, } from "@diffkit/icons"; -import { useQueryClient } from "@tanstack/react-query"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; import { useSyncExternalStore } from "react"; +import type { GitHubQueryScope } from "#/lib/github.query"; import { githubQueryKeys } from "#/lib/github.query"; import type { CommandPaletteSearchResult, - GitHubAccountSummary, IssueSummary, MyIssuesResult, MyPullsResult, @@ -49,14 +48,6 @@ function getIssueIcon(issue: IssueSummary) { return { icon: IssuesIcon, iconClassName: "text-green-500" }; } -function getGitHubAccountGroup(account: GitHubAccountSummary) { - return account.type === "Organization" - ? "GitHub Organizations" - : "GitHub Users"; -} - -const noopCommandAction = () => {}; - const routeApi = getRouteApi("/_protected"); export function useCommandItems(): CommandItem[] { @@ -195,45 +186,12 @@ export function getCommandSearchItems( const items: CommandItem[] = []; - for (const repo of result.repositories) { - items.push({ - id: `repo:${repo.id}`, - label: repo.fullName, - group: "GitHub Repositories", - icon: CodeIcon, - keywords: [repo.name, repo.owner, repo.language ?? ""].filter(Boolean), - action: { - type: "execute", - fn: noopCommandAction, - }, - meta: { - language: repo.language, - stars: repo.stars, - updatedAt: repo.updatedAt ?? undefined, - }, - }); - } - - for (const user of result.users) { - items.push({ - id: `github-account:${user.id}`, - label: user.login, - group: getGitHubAccountGroup(user), - icon: UserAddIcon, - keywords: [user.login, user.type], - action: { - type: "execute", - fn: noopCommandAction, - }, - }); - } - for (const pr of result.pulls) { const prState = getPrIcon(pr); items.push({ id: `pull:${pr.id}`, label: `#${pr.number} ${pr.title}`, - group: "GitHub Pull Requests", + group: "Pull Requests", icon: prState.icon, iconClassName: prState.iconClassName, keywords: [ @@ -243,8 +201,8 @@ export function getCommandSearchItems( String(pr.number), ].filter(Boolean), action: { - type: "execute", - fn: noopCommandAction, + type: "navigate", + to: `/${pr.repository.owner}/${pr.repository.name}/pull/${pr.number}`, }, meta: { repo: pr.repository.fullName, @@ -259,7 +217,7 @@ export function getCommandSearchItems( items.push({ id: `issue:${issue.id}`, label: `#${issue.number} ${issue.title}`, - group: "GitHub Issues", + group: "Issues", icon: issueState.icon, iconClassName: issueState.iconClassName, keywords: [ @@ -269,8 +227,8 @@ export function getCommandSearchItems( String(issue.number), ].filter(Boolean), action: { - type: "execute", - fn: noopCommandAction, + type: "navigate", + to: `/${issue.repository.owner}/${issue.repository.name}/issues/${issue.number}`, }, meta: { repo: issue.repository.fullName, @@ -282,3 +240,41 @@ export function getCommandSearchItems( return items; } + +export function cacheSearchResults( + queryClient: QueryClient, + scope: GitHubQueryScope, + result: CommandPaletteSearchResult, +) { + if (result.pulls.length > 0) { + queryClient.setQueryData( + githubQueryKeys.pulls.mine(scope), + (prev) => { + if (!prev) return prev; + const existingIds = new Set(prev.involved.map((p) => p.id)); + const newPulls = result.pulls.filter((p) => !existingIds.has(p.id)); + if (newPulls.length === 0) return prev; + return { + ...prev, + involved: [...prev.involved, ...newPulls], + }; + }, + ); + } + + if (result.issues.length > 0) { + queryClient.setQueryData( + githubQueryKeys.issues.mine(scope), + (prev) => { + if (!prev) return prev; + const existingIds = new Set(prev.mentioned.map((i) => i.id)); + const newIssues = result.issues.filter((i) => !existingIds.has(i.id)); + if (newIssues.length === 0) return prev; + return { + ...prev, + mentioned: [...prev.mentioned, ...newIssues], + }; + }, + ); + } +} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 2f01453..351bf22 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -4,7 +4,6 @@ import type { CommandPaletteSearchResult, CreateLabelInput, CreateReviewCommentInput, - GitHubAccountSummary, GitHubActor, GitHubLabel, IssueComment, @@ -80,12 +79,6 @@ type SearchItem = Awaited< type SearchResult = Awaited< ReturnType >["data"]; -type RepositorySearchItem = Awaited< - ReturnType ->["data"]["items"][number]; -type UserSearchItem = Awaited< - ReturnType ->["data"]["items"][number]; type AuthenticatedUserRepo = Awaited< ReturnType >["data"][number]; @@ -406,8 +399,6 @@ function normalizeCommandPaletteSearchQuery(query: string) { function emptyCommandPaletteSearchResult(): CommandPaletteSearchResult { return { - repositories: [], - users: [], pulls: [], issues: [], }; @@ -458,42 +449,6 @@ function mapActor(user: GitHubApiUser | null | undefined): GitHubActor | null { }; } -function mapGitHubAccountSearchItem( - user: UserSearchItem, -): GitHubAccountSummary | null { - if (!user.login) { - return null; - } - - return { - id: user.id, - login: user.login, - avatarUrl: user.avatar_url ?? "", - url: user.html_url ?? `https://github.com/${user.login}`, - type: user.type ?? "User", - }; -} - -function mapRepositorySearchItem(repo: RepositorySearchItem): UserRepoSummary { - const ownerLogin = repo.owner?.login ?? ""; - const fullName = - repo.full_name ?? [ownerLogin, repo.name].filter(Boolean).join("/"); - const fallbackOwner = fullName.split("/")[0] ?? ""; - - return { - id: repo.id, - name: repo.name, - fullName, - description: repo.description ?? null, - stars: repo.stargazers_count ?? 0, - language: repo.language ?? null, - updatedAt: repo.updated_at ?? null, - isPrivate: repo.private ?? false, - url: repo.html_url ?? `https://github.com/${fullName}`, - owner: ownerLogin || fallbackOwner, - }; -} - function mapReviewerCandidate(user: GitHubApiUser): RepoCollaborator | null { const actor = mapActor(user); if (!actor) { @@ -697,7 +652,15 @@ function mapPullSearchItems(items: SearchItem[]) { return null; } - return mapPullSummary(item, repository); + const mergedAt = + item.pull_request && "merged_at" in item.pull_request + ? (item.pull_request.merged_at as string | null) + : undefined; + + return mapPullSummary( + { ...item, merged_at: mergedAt ?? null }, + repository, + ); }) .filter((item): item is PullSummary => Boolean(item)); } @@ -2784,41 +2747,18 @@ export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) return emptyCommandPaletteSearchResult(); } + const viewer = await getViewer(context); + const login = viewer.login; + const perPage = clampCommandSearchPerPage(data.perPage); - const [repositories, users, pullItems, issueItems] = await Promise.all([ - safeCommandPaletteSearch({ - label: "repositories", - fallback: [] as UserRepoSummary[], - task: async () => { - const response = await context.octokit.rest.search.repos({ - q: `${query} in:name,description fork:true`, - per_page: perPage, - sort: "updated", - order: "desc", - }); - return response.data.items.map(mapRepositorySearchItem); - }, - }), - safeCommandPaletteSearch({ - label: "users", - fallback: [] as GitHubAccountSummary[], - task: async () => { - const response = await context.octokit.rest.search.users({ - q: `${query} in:login,fullname`, - per_page: perPage, - }); - return response.data.items - .map((user) => mapGitHubAccountSearchItem(user)) - .filter((user): user is GitHubAccountSummary => Boolean(user)); - }, - }), + const [pullItems, issueItems] = await Promise.all([ safeCommandPaletteSearch({ label: "pull requests", fallback: [] as SearchItem[], task: async () => { const response = await context.octokit.rest.search.issuesAndPullRequests({ - q: `${query} is:pr in:title,body archived:false`, + q: `${query} is:pr involves:${login} archived:false`, per_page: perPage, sort: "updated", order: "desc", @@ -2832,7 +2772,7 @@ export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) task: async () => { const response = await context.octokit.rest.search.issuesAndPullRequests({ - q: `${query} is:issue in:title,body archived:false`, + q: `${query} is:issue involves:${login} archived:false`, per_page: perPage, sort: "updated", order: "desc", @@ -2843,8 +2783,6 @@ export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) ]); return { - repositories, - users, pulls: mapPullSearchItems(pullItems), issues: mapIssueSearchItems(issueItems), }; diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 21036bb..6f38db5 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -122,8 +122,6 @@ export type MyIssuesResult = { }; export type CommandPaletteSearchResult = { - repositories: UserRepoSummary[]; - users: GitHubAccountSummary[]; pulls: PullSummary[]; issues: IssueSummary[]; };