From d96cd890f18a46961fd3ba4314243e65262b5c1f Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Wed, 29 Apr 2026 17:13:02 -0300 Subject: [PATCH 1/6] feat: pr list on repo page --- src-tauri/src/commands.rs | 153 ++++++++++++++++++ src-tauri/src/github.rs | 2 + src-tauri/src/lib.rs | 1 + src/components/dashboard.tsx | 7 + src/components/repo-pr-list.tsx | 269 ++++++++++++++++++++++++++++++++ src/lib/api.ts | 22 +++ 6 files changed, 454 insertions(+) create mode 100644 src/components/repo-pr-list.tsx diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8a93f54..3faf855 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -284,6 +284,7 @@ fn pr_from_node(n: GqlPrNode) -> Option { updated_at: n.updated_at?, comments: n.comments?.total_count, draft: n.is_draft.unwrap_or(false), + state: None, }) } @@ -1376,6 +1377,158 @@ pub async fn get_pause_status(app: tauri::AppHandle) -> AppResult> { Ok(notifications::paused_until(&app)) } +// ── Repo PR list ─────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct RepoPrPage { + pub items: Vec, + pub total: i64, + pub next_cursor: Option, +} + +const REPO_PRS_QUERY: &str = r#" +query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) { + repository(owner: $owner, name: $name) { + pullRequests( + first: 30 + after: $after + states: $states + orderBy: {field: UPDATED_AT, direction: DESC} + ) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + databaseId + number + title + url + updatedAt + isDraft + state + comments { totalCount } + author { login avatarUrl } + repository { nameWithOwner } + } + } + } +} +"#; + +#[derive(Deserialize)] +struct RepoPrsData { + repository: Option, +} + +#[derive(Deserialize)] +struct RepoPrsRepo { + #[serde(rename = "pullRequests")] + pull_requests: RepoPrsConnection, +} + +#[derive(Deserialize)] +struct RepoPrsConnection { + #[serde(rename = "totalCount")] + total_count: i64, + #[serde(rename = "pageInfo")] + page_info: RepoPrsPageInfo, + nodes: Vec, +} + +#[derive(Deserialize)] +struct RepoPrsPageInfo { + #[serde(rename = "hasNextPage")] + has_next_page: bool, + #[serde(rename = "endCursor")] + end_cursor: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RepoPrNode { + #[serde(rename = "databaseId")] + database_id: Option, + number: Option, + title: Option, + url: Option, + #[serde(rename = "updatedAt")] + updated_at: Option, + #[serde(rename = "isDraft")] + is_draft: Option, + state: Option, + comments: Option, + author: Option, + repository: Option, +} + +fn repo_pr_from_node(n: RepoPrNode) -> Option { + let author = n.author?; + let repo = n.repository?; + Some(PullRequestRef { + id: n.database_id?, + number: n.number?, + title: n.title?, + html_url: n.url?, + repo: repo.name_with_owner, + author: PrAuthor { + login: author.login, + avatar_url: author.avatar_url, + }, + updated_at: n.updated_at?, + comments: n.comments?.total_count, + draft: n.is_draft.unwrap_or(false), + state: n.state, + }) +} + +#[tauri::command] +pub async fn list_repo_prs( + owner: String, + name: String, + scope: String, + after: Option, +) -> AppResult { + let states: Option> = match scope.as_str() { + "open" => Some(vec!["OPEN"]), + "closed" => Some(vec!["CLOSED", "MERGED"]), + "all" => None, + other => { + return Err(AppError::InvalidToken(format!("scope inválido: {other}"))); + } + }; + + let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?; + let client = Client::new(token)?; + + let variables = serde_json::json!({ + "owner": owner, + "name": name, + "states": states, + "after": after, + }); + + let data: RepoPrsData = client.graphql(REPO_PRS_QUERY, variables).await?; + let repo = data + .repository + .ok_or_else(|| AppError::InvalidToken("repositório não encontrado".into()))?; + + let items: Vec = repo + .pull_requests + .nodes + .into_iter() + .filter_map(repo_pr_from_node) + .collect(); + + Ok(RepoPrPage { + items, + total: repo.pull_requests.total_count, + next_cursor: if repo.pull_requests.page_info.has_next_page { + repo.pull_requests.page_info.end_cursor + } else { + None + }, + }) +} + // ── GitHub API ───────────────────────────────────────── #[tauri::command] diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs index 9bfb48d..f05bc40 100644 --- a/src-tauri/src/github.rs +++ b/src-tauri/src/github.rs @@ -113,6 +113,8 @@ pub struct PullRequestRef { pub updated_at: String, pub comments: i64, pub draft: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option, } // ── Device Flow types ────────────────────────────────── diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ec3838f..d312b72 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,6 +59,7 @@ pub fn run() { commands::add_tracked_org, commands::remove_tracked_org, commands::list_all_repos, + commands::list_repo_prs, commands::get_dashboard, commands::get_pr_details, commands::get_pr_files, diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index 59a9d0c..031d4da 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -29,6 +29,7 @@ import { type WatchedRepo, } from '@/lib/api' import { formatRelative } from '@/lib/format' +import { RepoPrList } from '@/components/repo-pr-list' type State = | { status: 'loading' } @@ -148,6 +149,12 @@ export function Dashboard({ repo, onClear, onSelectPr }: Props) { {state.status === 'ready' && !noWatched && ( )} + + {state.status === 'ready' && repo && ( +
+ +
+ )} diff --git a/src/components/repo-pr-list.tsx b/src/components/repo-pr-list.tsx new file mode 100644 index 0000000..9c14eeb --- /dev/null +++ b/src/components/repo-pr-list.tsx @@ -0,0 +1,269 @@ +import { + CircleDot, + Clock, + GitMerge, + GitPullRequestClosed, + Loader2, + MessageSquare, +} from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Badge } from '@/components/ui/badge' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { + api, + type PullRequestRef, + type RepoPrScope, + type WatchedRepo, +} from '@/lib/api' +import { formatRelative } from '@/lib/format' + +type State = + | { status: 'loading' } + | { + status: 'ready' + items: PullRequestRef[] + total: number + nextCursor: string | null + } + | { status: 'error'; message: string } + +const SCOPES: { value: RepoPrScope; label: string }[] = [ + { value: 'open', label: 'Abertos' }, + { value: 'closed', label: 'Fechados' }, + { value: 'all', label: 'Todos' }, +] + +type Props = { + repo: WatchedRepo + onSelectPr?: (pr: PullRequestRef) => void +} + +export function RepoPrList({ repo, onSelectPr }: Props) { + const [scope, setScope] = useState('open') + const [state, setState] = useState({ status: 'loading' }) + const [loadingMore, setLoadingMore] = useState(false) + const reqId = useRef(0) + + const [owner, name] = repo.full_name.split('/') + + const load = useCallback( + async (nextScope: RepoPrScope) => { + const id = ++reqId.current + setState({ status: 'loading' }) + try { + const page = await api.listRepoPrs(owner, name, nextScope) + if (id !== reqId.current) return + setState({ + status: 'ready', + items: page.items, + total: page.total, + nextCursor: page.next_cursor, + }) + } catch (err) { + if (id !== reqId.current) return + setState({ status: 'error', message: String(err) }) + } + }, + [owner, name], + ) + + useEffect(() => { + load(scope) + }, [load, scope]) + + async function loadMore() { + if (state.status !== 'ready' || !state.nextCursor || loadingMore) return + setLoadingMore(true) + const id = reqId.current + try { + const page = await api.listRepoPrs(owner, name, scope, state.nextCursor) + if (id !== reqId.current) return + setState((prev) => + prev.status === 'ready' + ? { + status: 'ready', + items: [...prev.items, ...page.items], + total: page.total, + nextCursor: page.next_cursor, + } + : prev, + ) + } catch (err) { + if (id !== reqId.current) return + setState({ status: 'error', message: String(err) }) + } finally { + setLoadingMore(false) + } + } + + return ( + + + + Pull requests do repositório + {state.status === 'ready' && ( + + {state.total} + + )} + +
+ {SCOPES.map((s) => ( + + ))} +
+
+ + {state.status === 'loading' && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {state.status === 'error' && ( +

+ {state.message} +

+ )} + + {state.status === 'ready' && state.items.length === 0 && ( +

+ Nenhum PR neste filtro. +

+ )} + + {state.status === 'ready' && state.items.length > 0 && ( + <> +
    + {state.items.map((item, i) => ( +
  • 0 ? 'border-t border-border' : ''} + > + +
  • + ))} +
+ {state.nextCursor && ( +
+ +
+ )} + + )} +
+
+ ) +} + +function PrStateIcon({ + state, + draft, +}: { + state: string | undefined + draft: boolean +}) { + const cls = 'size-4 shrink-0 mt-1' + if (state === 'MERGED') { + return + } + if (state === 'CLOSED') { + return + } + return ( + + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 5791898..4cb0ea5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -77,6 +77,15 @@ export type PullRequestRef = { updated_at: string comments: number draft: boolean + state?: string +} + +export type RepoPrScope = 'open' | 'closed' | 'all' + +export type RepoPrPage = { + items: PullRequestRef[] + total: number + next_cursor: string | null } export type ContributorStat = { @@ -242,6 +251,19 @@ export const api = { getDashboard: (repoFullName?: string | null) => invoke('get_dashboard', { repoFullName: repoFullName ?? null }), + listRepoPrs: ( + owner: string, + name: string, + scope: RepoPrScope, + after?: string | null, + ) => + invoke('list_repo_prs', { + owner, + name, + scope, + after: after ?? null, + }), + getPrDetails: (owner: string, name: string, number: number) => invoke('get_pr_details', { owner, name, number }), From 23dfc23e02d42994cba0699b0e244aad16f7df60 Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Wed, 29 Apr 2026 18:35:17 -0300 Subject: [PATCH 2/6] feat: command palette with prefix-based dynamic sections Ctrl/Cmd+K opens a cmdk-based palette. Reserved prefixes (repo:, pr:, org:) render as blue pills via an input overlay; the parser splits the query into repoTokens / prTokens / orgTokens / freeTerm. Sections appear dynamically: - Repos: watched repos matching free term + repo: tokens - Pull Requests: global search (hidden when repo: tokens are present) - PRs em : one subsection per matched watched repo (max 5) when repo: tokens are typed, listing PRs filtered by free term + pr: Backend: search_prs (scoped to watched repos) and search_prs_in_repo (single repo) GraphQL commands. Sidebar: new SearchSidebarLink above Inbox; tightened collapsed nav padding for a uniform 4px rhythm across top-action icons. Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 3 + package.json | 1 + src-tauri/src/commands.rs | 108 ++++++++ src-tauri/src/lib.rs | 2 + src/App.tsx | 29 +- src/components/command-palette.tsx | 361 +++++++++++++++++++++++++ src/components/inbox-sidebar-link.tsx | 2 +- src/components/repo-list.tsx | 4 +- src/components/search-sidebar-link.tsx | 39 +++ src/components/ui/command.tsx | 204 ++++++++++++++ src/lib/api.ts | 6 + 11 files changed, 755 insertions(+), 4 deletions(-) create mode 100644 src/components/command-palette.tsx create mode 100644 src/components/search-sidebar-link.tsx create mode 100644 src/components/ui/command.tsx diff --git a/bun.lock b/bun.lock index cb73f64..3ec914c 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@tauri-apps/api": "^2.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^1.11.0", "radix-ui": "^1.4.3", "react": "^19.2.5", @@ -550,6 +551,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], diff --git a/package.json b/package.json index 6714592..4388ef4 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@tauri-apps/api": "^2.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^1.11.0", "radix-ui": "^1.4.3", "react": "^19.2.5", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3faf855..1306bd9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1529,6 +1529,114 @@ pub async fn list_repo_prs( }) } +// ── PR search (command palette) ──────────────────────── + +const PR_SEARCH_QUERY: &str = r#" +query($q: String!) { + search(query: $q, type: ISSUE, first: 15) { + nodes { + ... on PullRequest { + databaseId + number + title + url + updatedAt + isDraft + state + comments { totalCount } + author { login avatarUrl } + repository { nameWithOwner } + } + } + } +} +"#; + +#[derive(Deserialize)] +struct PrSearchData { + search: PrSearchConnection, +} + +#[derive(Deserialize)] +struct PrSearchConnection { + nodes: Vec, +} + +#[tauri::command] +pub async fn search_prs( + query: String, + db: State<'_, DbState>, +) -> AppResult> { + let trimmed = query.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + let repos = { + let conn = db.0.lock().unwrap(); + db::list_watched(&conn) + }; + if repos.is_empty() { + return Ok(Vec::new()); + } + + let repo_filter = repos + .iter() + .map(|r| format!("repo:{}", r.full_name)) + .collect::>() + .join(" "); + + let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?; + let client = Client::new(token)?; + + let q = format!("is:pr {trimmed} {repo_filter}"); + let variables = serde_json::json!({ "q": q }); + + let data: PrSearchData = client.graphql(PR_SEARCH_QUERY, variables).await?; + let items: Vec = data + .search + .nodes + .into_iter() + .filter_map(repo_pr_from_node) + .collect(); + + Ok(items) +} + +#[tauri::command] +pub async fn search_prs_in_repo( + owner: String, + name: String, + query: String, +) -> AppResult> { + let trimmed_owner = owner.trim(); + let trimmed_name = name.trim(); + if trimmed_owner.is_empty() || trimmed_name.is_empty() { + return Ok(Vec::new()); + } + + let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?; + let client = Client::new(token)?; + + let term = query.trim(); + let q = if term.is_empty() { + format!("is:pr repo:{trimmed_owner}/{trimmed_name}") + } else { + format!("is:pr {term} repo:{trimmed_owner}/{trimmed_name}") + }; + let variables = serde_json::json!({ "q": q }); + + let data: PrSearchData = client.graphql(PR_SEARCH_QUERY, variables).await?; + let items: Vec = data + .search + .nodes + .into_iter() + .filter_map(repo_pr_from_node) + .collect(); + + Ok(items) +} + // ── GitHub API ───────────────────────────────────────── #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d312b72..48e0af2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,8 @@ pub fn run() { commands::remove_tracked_org, commands::list_all_repos, commands::list_repo_prs, + commands::search_prs, + commands::search_prs_in_repo, commands::get_dashboard, commands::get_pr_details, commands::get_pr_files, diff --git a/src/App.tsx b/src/App.tsx index 42af210..aa7ceae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,14 @@ import { Loader2, PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' +import { CommandPalette } from '@/components/command-palette' import { Dashboard } from '@/components/dashboard' import { Inbox } from '@/components/inbox' import { InboxSidebarLink } from '@/components/inbox-sidebar-link' import { LoginForm } from '@/components/login-form' import { PrViewer } from '@/components/pr-viewer' import { RepoList } from '@/components/repo-list' +import { SearchSidebarLink } from '@/components/search-sidebar-link' import { SettingsDialog } from '@/components/settings-dialog' import { UserMenu } from '@/components/user-menu' import { @@ -32,6 +34,7 @@ function App() { const [selectedRepo, setSelectedRepo] = useState(null) const [selectedPr, setSelectedPr] = useState(null) const [inboxOpen, setInboxOpen] = useState(false) + const [paletteOpen, setPaletteOpen] = useState(false) useEffect(() => { api @@ -57,9 +60,15 @@ function App() { useEffect(() => { if (state.kind !== 'authenticated') return function onKeydown(e: KeyboardEvent) { - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') { + const mod = e.ctrlKey || e.metaKey + if (!mod) return + const key = e.key.toLowerCase() + if (key === 'b') { e.preventDefault() toggleCollapsed() + } else if (key === 'k') { + e.preventDefault() + setPaletteOpen((prev) => !prev) } } window.addEventListener('keydown', onKeydown) @@ -146,6 +155,10 @@ function App() { )} + setPaletteOpen(true)} + /> )} + + { + setSelectedRepo(repo) + setSelectedPr(null) + setInboxOpen(false) + }} + onSelectPr={(pr) => { + setSelectedPr(pr) + setInboxOpen(false) + }} + /> ) } diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx new file mode 100644 index 0000000..3aeeaad --- /dev/null +++ b/src/components/command-palette.tsx @@ -0,0 +1,361 @@ +import { GitPullRequest, Lock, MessageSquare } from 'lucide-react' +import type { ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandLoading, + CommandShortcut, +} from '@/components/ui/command' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { + api, + type PullRequestRef, + type WatchedRepo, +} from '@/lib/api' + +type ParsedQuery = { + repoTokens: string[] + prTokens: string[] + orgTokens: string[] + freeTerm: string +} + +const RESERVED_PREFIX_RE = /^(repos?|prs?|org):(.*)$/i +const HIGHLIGHT_PREFIX_RE = /^(repos?|prs?|org):/i + +function parseQuery(raw: string): ParsedQuery { + const tokens = raw.trim().split(/\s+/).filter(Boolean) + const repoTokens: string[] = [] + const prTokens: string[] = [] + const orgTokens: string[] = [] + const freeTerms: string[] = [] + + for (const tok of tokens) { + const m = tok.match(RESERVED_PREFIX_RE) + if (m) { + const kind = m[1].toLowerCase() + const value = m[2] + if (kind.startsWith('repo')) { + if (value) repoTokens.push(value) + } else if (kind.startsWith('pr')) { + if (value) prTokens.push(value) + } else if (kind === 'org') { + if (value) orgTokens.push(value) + } + } else { + freeTerms.push(tok) + } + } + + return { + repoTokens, + prTokens, + orgTokens, + freeTerm: freeTerms.join(' '), + } +} + +function highlightQuery(value: string): ReactNode { + const tokens = value.split(/(\s+)/) + return ( + <> + {tokens.map((tok, idx) => { + if (!tok) return null + if (/^\s+$/.test(tok)) return {tok} + const m = tok.match(HIGHLIGHT_PREFIX_RE) + if (m) { + const prefix = m[0] + const rest = tok.slice(prefix.length) + return ( + + {prefix} + {rest && {rest}} + + ) + } + return {tok} + })} + + ) +} + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + onSelectRepo: (repo: WatchedRepo) => void + onSelectPr: (pr: PullRequestRef) => void +} + +export function CommandPalette({ + open, + onOpenChange, + onSelectRepo, + onSelectPr, +}: Props) { + const [input, setInput] = useState('') + const [repos, setRepos] = useState([]) + const [globalPrs, setGlobalPrs] = useState([]) + const [globalPrsLoading, setGlobalPrsLoading] = useState(false) + const [repoPrsByRepoId, setRepoPrsByRepoId] = useState< + Record + >({}) + const [repoPrsLoading, setRepoPrsLoading] = useState(false) + const globalReqIdRef = useRef(0) + const repoPrsReqIdRef = useRef(0) + + useEffect(() => { + if (!open) return + setInput('') + setGlobalPrs([]) + setRepoPrsByRepoId({}) + api + .getWatchedRepos() + .then(setRepos) + .catch(() => setRepos([])) + }, [open]) + + const parsed = useMemo(() => parseQuery(input), [input]) + + const showRepos = parsed.prTokens.length === 0 + const showGlobalPrs = + parsed.repoTokens.length === 0 && + (parsed.freeTerm.length > 0 || parsed.prTokens.length > 0) + const showRepoPrs = parsed.repoTokens.length > 0 + + const filteredRepos = useMemo(() => { + if (!showRepos) return [] + if (parsed.repoTokens.length === 0 && !parsed.freeTerm) { + return repos.slice(0, 8) + } + const needles = [ + ...parsed.repoTokens, + ...(parsed.freeTerm ? [parsed.freeTerm] : []), + ].map((s) => s.toLowerCase()) + return repos + .filter((r) => { + const haystack = `${r.full_name} ${r.name}`.toLowerCase() + return needles.every((n) => haystack.includes(n)) + }) + .slice(0, 8) + }, [repos, showRepos, parsed.repoTokens, parsed.freeTerm]) + + const matchedRepos = useMemo(() => { + if (!showRepoPrs) return [] + const needles = parsed.repoTokens.map((s) => s.toLowerCase()) + return repos + .filter((r) => { + const haystack = `${r.full_name} ${r.name}`.toLowerCase() + return needles.some((n) => haystack.includes(n)) + }) + .slice(0, 5) + }, [repos, showRepoPrs, parsed.repoTokens]) + + const matchedReposKey = matchedRepos.map((r) => r.id).join(',') + + const prQueryTerm = useMemo( + () => [parsed.freeTerm, ...parsed.prTokens].filter(Boolean).join(' '), + [parsed.freeTerm, parsed.prTokens], + ) + + useEffect(() => { + if (!open) return + if (!showGlobalPrs || prQueryTerm.length === 0) { + setGlobalPrs([]) + setGlobalPrsLoading(false) + return + } + + const id = ++globalReqIdRef.current + setGlobalPrsLoading(true) + const handle = window.setTimeout(() => { + api + .searchPrs(prQueryTerm) + .then((items) => { + if (globalReqIdRef.current !== id) return + setGlobalPrs(items) + setGlobalPrsLoading(false) + }) + .catch(() => { + if (globalReqIdRef.current !== id) return + setGlobalPrs([]) + setGlobalPrsLoading(false) + }) + }, 250) + + return () => window.clearTimeout(handle) + }, [open, showGlobalPrs, prQueryTerm]) + + useEffect(() => { + if (!open || matchedRepos.length === 0) { + setRepoPrsByRepoId({}) + setRepoPrsLoading(false) + return + } + + const id = ++repoPrsReqIdRef.current + setRepoPrsLoading(true) + const handle = window.setTimeout(async () => { + const results = await Promise.all( + matchedRepos.map(async (r) => { + try { + const items = await api.searchPrsInRepo( + r.owner_login, + r.name, + prQueryTerm, + ) + return [r.id, items] as const + } catch { + return [r.id, [] as PullRequestRef[]] as const + } + }), + ) + if (repoPrsReqIdRef.current !== id) return + const next: Record = {} + for (const [rid, items] of results) { + next[rid] = items + } + setRepoPrsByRepoId(next) + setRepoPrsLoading(false) + }, 250) + + return () => window.clearTimeout(handle) + // matchedReposKey collapses matchedRepos identity to a stable string. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, matchedReposKey, prQueryTerm]) + + function handleSelectRepo(repo: WatchedRepo) { + onOpenChange(false) + onSelectRepo(repo) + } + + function handleSelectPr(pr: PullRequestRef) { + onOpenChange(false) + onSelectPr(pr) + } + + return ( + + + + + {globalPrsLoading || repoPrsLoading ? 'Buscando…' : 'Nada encontrado.'} + + + {showRepos && filteredRepos.length > 0 && ( + + {filteredRepos.map((repo) => ( + handleSelectRepo(repo)} + > + + + + {repo.owner_login.slice(0, 2).toUpperCase()} + + + {repo.full_name} + {repo.private && ( + + )} + + + ))} + + )} + + {showGlobalPrs && ( + + {globalPrsLoading && Buscando…} + {!globalPrsLoading && + globalPrs.map((pr) => ( + handleSelectPr(pr)} + > + + + + ))} + + )} + + {showRepoPrs && + matchedRepos.map((repo) => { + const repoPrs = repoPrsByRepoId[repo.id] ?? [] + return ( + + {repoPrsLoading && Buscando…} + {!repoPrsLoading && repoPrs.length === 0 && ( +
+ Nenhum PR encontrado. +
+ )} + {!repoPrsLoading && + repoPrs.map((pr) => ( + handleSelectPr(pr)} + > + + + + ))} +
+ ) + })} +
+
+ ) +} + +function PrIcon({ draft }: { draft: boolean }) { + return ( + + ) +} + +function PrInfo({ pr }: { pr: PullRequestRef }) { + return ( + <> +
+ {pr.title} + + {pr.repo} #{pr.number} · {pr.author.login} + +
+ {pr.comments > 0 && ( + + + {pr.comments} + + )} + + ) +} diff --git a/src/components/inbox-sidebar-link.tsx b/src/components/inbox-sidebar-link.tsx index cd798e9..06bb48e 100644 --- a/src/components/inbox-sidebar-link.tsx +++ b/src/components/inbox-sidebar-link.tsx @@ -17,7 +17,7 @@ export function InboxSidebarLink({ collapsed, active, onClick }: Props) { type="button" onClick={onClick} title={unread > 0 ? `Caixa de entrada (${unread})` : 'Caixa de entrada'} - className={`group relative mx-auto my-1 flex size-9 items-center justify-center rounded-md transition-colors ${ + className={`group relative mx-auto mt-1 flex size-9 items-center justify-center rounded-md transition-colors ${ active ? 'bg-sidebar-accent text-sidebar-foreground' : 'text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-foreground' diff --git a/src/components/repo-list.tsx b/src/components/repo-list.tsx index 999b1f4..88d9c24 100644 --- a/src/components/repo-list.tsx +++ b/src/components/repo-list.tsx @@ -114,9 +114,9 @@ export function RepoList({ if (collapsed) { return ( -