diff --git a/README.md b/README.md index d6d0a00..cd85895 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,22 @@ Prism is a lightweight, cross-platform desktop app (Windows / macOS / Linux) bui

PR review with markdown body and diff

+

+ Per-repo PR list with author, age and comment counts +

+

+ Command palette — fuzzy search across repos and PRs with prefix filters +

+

+ Inbox with collapsible repo groups and deduped clusters +

## Highlights -- **Inbox** — mirrors GitHub's notification API; mark-as-read syncs both ways. +- **Inbox** — mirrors GitHub's notification API; mark-as-read syncs both ways. Identical notifications (e.g. the same flaky CI check firing six times) collapse into a single row with a `×N` badge; repo groups are collapsible and have a one-click "mark this repo as read". +- **Per-repo PR list** — open a watched repo to see its open / closed / all PRs with author, age and comment counts; click through to the full review. +- **Command palette** (`Ctrl/Cmd+K`) — fuzzy search across watched repos and PRs. Prefix filters (`repo:`, `pr:`) narrow scope; `repo: pr:` searches PRs only inside that repo. +- **Sidebar with hover preview** — collapse the sidebar to a 56px rail; hovering an org avatar shows its watched repos in a hover card you can click through. - **System tray** — unread badge, pause notifications for 1h / 4h, "mark all read" without opening the app. - **Native push** — fires only on relevant reasons (review_requested, mention, comment, assign, state_change, ci_activity); skips first-sync and collapses bursts > 3. - **Drag-select review** — drag across diff lines (LEFT or RIGHT side) to comment on a range. Auto-creates the pending review on first comment; submit with Approve / Comment / Request Changes. @@ -125,6 +137,7 @@ src/ ## Status - ✅ Notifications, tray, drag-select review (v0.1.1) +- ✅ Per-repo PR list, command palette, inbox dedupe + collapsible groups, sidebar org hover preview (v0.1.2) - ⏳ Deep-link from native notification body click (needs custom URI scheme) - ⏳ Edit / delete pending review comments before submit - ⏳ Code signing for macOS / Windows 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/docs/screenshots/command-pallet.png b/docs/screenshots/command-pallet.png new file mode 100644 index 0000000..f34ad0a Binary files /dev/null and b/docs/screenshots/command-pallet.png differ diff --git a/docs/screenshots/list_prs.png b/docs/screenshots/list_prs.png new file mode 100644 index 0000000..0b92099 Binary files /dev/null and b/docs/screenshots/list_prs.png differ diff --git a/docs/screenshots/notifications.png b/docs/screenshots/notifications.png new file mode 100644 index 0000000..bf7f80f Binary files /dev/null and b/docs/screenshots/notifications.png differ diff --git a/package.json b/package.json index 6714592..061e933 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", @@ -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/Cargo.lock b/src-tauri/Cargo.lock index e19bbf2..93339dd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2993,7 +2993,7 @@ dependencies = [ [[package]] name = "prism" -version = "0.1.1" +version = "0.1.2" dependencies = [ "log", "open", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 90ecea6..347a5ef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "prism" -version = "0.1.1" +version = "0.1.2" description = "Desktop client for GitHub Pull Requests." authors = ["Israel Araujo de Oliveira"] license = "MIT" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8a93f54..0be7aec 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, }) } @@ -1317,6 +1318,14 @@ pub async fn mark_all_notifications_read(app: tauri::AppHandle) -> AppResult<()> notifications::mark_all_read(&app).await } +#[tauri::command] +pub async fn mark_repo_notifications_read( + repo_full: String, + app: tauri::AppHandle, +) -> AppResult<()> { + notifications::mark_repo_read(&app, &repo_full).await +} + #[tauri::command] pub async fn sync_notifications_now(app: tauri::AppHandle) -> AppResult<()> { notifications::sync_once(&app).await.map(|_| ()) @@ -1376,6 +1385,266 @@ 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 + }, + }) +} + +// ── 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/db.rs b/src-tauri/src/db.rs index 0d803b6..5c4672f 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -252,6 +252,14 @@ pub fn mark_all_notifications_read(conn: &Connection) { conn.execute("UPDATE notifications SET unread = 0", []).unwrap(); } +pub fn mark_repo_notifications_read(conn: &Connection, repo_full: &str) { + conn.execute( + "UPDATE notifications SET unread = 0 WHERE repo_full = ?1", + params![repo_full], + ) + .unwrap(); +} + pub fn get_sync_state(conn: &Connection, key: &str) -> Option { conn.query_row( "SELECT value FROM sync_state WHERE key = ?1", diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index e214837..7113d93 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -11,6 +11,8 @@ pub enum AppError { Storage(#[from] std::io::Error), #[error("network: {0}")] Network(#[from] reqwest::Error), + #[error("{0}")] + Other(String), } impl Serialize for AppError { diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs index 9bfb48d..93b89bc 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 ────────────────────────────────── @@ -382,6 +384,28 @@ impl Client { Ok(()) } + pub async fn mark_repo_notifications_read( + &self, + owner: &str, + repo: &str, + ) -> AppResult<()> { + let res = self + .request( + reqwest::Method::PUT, + &format!("/repos/{owner}/{repo}/notifications"), + ) + .json(&serde_json::json!({ "read": true })) + .send() + .await?; + if res.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(AppError::InvalidToken( + "GitHub rejected the token (401)".into(), + )); + } + res.error_for_status()?; + Ok(()) + } + pub async fn graphql( &self, query: &str, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ec3838f..516d49b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,6 +59,9 @@ pub fn run() { commands::add_tracked_org, 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, @@ -73,6 +76,7 @@ pub fn run() { commands::unread_notification_count, commands::mark_notification_read, commands::mark_all_notifications_read, + commands::mark_repo_notifications_read, commands::sync_notifications_now, commands::list_notification_mutes, commands::set_notification_mute, diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 28e0218..cf1ded8 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -143,6 +143,23 @@ pub async fn mark_all_read(app: &AppHandle) -> AppResult<()> { Ok(()) } +pub async fn mark_repo_read(app: &AppHandle, repo_full: &str) -> AppResult<()> { + let (owner, repo) = repo_full + .split_once('/') + .ok_or_else(|| AppError::Other(format!("invalid repo_full: {repo_full}")))?; + let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?; + let client = Client::new(token)?; + client.mark_repo_notifications_read(owner, repo).await?; + { + let state = app.state::(); + let conn = state.0.lock().unwrap(); + db::mark_repo_notifications_read(&conn, repo_full); + } + tray::update_title(app); + let _ = app.emit("notifications:changed", ()); + Ok(()) +} + pub fn spawn_loop(app: AppHandle) { tauri::async_runtime::spawn(async move { let mut backoff = MIN_POLL_SECS; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b20c291..0f5c347 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Prism", - "version": "0.1.1", + "version": "0.1.2", "identifier": "io.github.israelaraujo70.prism", "build": { "frontendDist": "../dist", 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/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/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/inbox.tsx b/src/components/inbox.tsx index 949a9cb..d295c26 100644 --- a/src/components/inbox.tsx +++ b/src/components/inbox.tsx @@ -1,6 +1,7 @@ import { Bell, CheckCheck, + ChevronRight, ExternalLink, Inbox as InboxIcon, RefreshCw, @@ -12,38 +13,141 @@ import { api, type NotificationRow, type PullRequestRef } from '@/lib/api' import { iconForReason, labelForReason } from '@/lib/reasons' import { useNotifications } from '@/lib/use-notifications' +const COLLAPSED_KEY = 'prism.collapsed-inbox-repos' + type Props = { onSelectPr: (pr: PullRequestRef) => void } +type Cluster = { + key: string + items: NotificationRow[] + representative: NotificationRow + unread: number + latest: string + oldest: string +} + type Group = { repo: string - items: NotificationRow[] + clusters: Cluster[] + unread: number + total: number latest: string } +function loadCollapsed(): Set { + try { + const raw = localStorage.getItem(COLLAPSED_KEY) + if (!raw) return new Set() + return new Set(JSON.parse(raw) as string[]) + } catch { + return new Set() + } +} + +function saveCollapsed(set: Set) { + localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...set])) +} + +function clusterKey(item: NotificationRow): string { + return `${item.repo_full}|${item.subject_type}|${item.pr_number ?? ''}|${item.title}` +} + export function Inbox({ onSelectPr }: Props) { - const { items, unread, loading, markRead, markAllRead, syncNow } = - useNotifications() + const { + items, + unread, + loading, + markRead, + markAllRead, + markRepoRead, + syncNow, + } = useNotifications() const [syncing, setSyncing] = useState(false) const [error, setError] = useState(null) + const [collapsedRepos, setCollapsedRepos] = useState>(() => + loadCollapsed(), + ) + const [expandedClusters, setExpandedClusters] = useState>( + () => new Set(), + ) const groups = useMemo(() => { - const map = new Map() + const repoMap = new Map>() for (const item of items) { - let group = map.get(item.repo_full) - if (!group) { - group = { repo: item.repo_full, items: [], latest: item.updated_at } - map.set(item.repo_full, group) + let clusterMap = repoMap.get(item.repo_full) + if (!clusterMap) { + clusterMap = new Map() + repoMap.set(item.repo_full, clusterMap) + } + const key = clusterKey(item) + let cluster = clusterMap.get(key) + if (!cluster) { + cluster = { + key, + items: [], + representative: item, + unread: 0, + latest: item.updated_at, + oldest: item.updated_at, + } + clusterMap.set(key, cluster) + } + cluster.items.push(item) + if (item.unread) cluster.unread += 1 + if (item.updated_at > cluster.latest) { + cluster.latest = item.updated_at + cluster.representative = item + } + if (item.updated_at < cluster.oldest) cluster.oldest = item.updated_at + } + + const out: Group[] = [] + for (const [repo, clusterMap] of repoMap) { + const clusters = Array.from(clusterMap.values()).sort((a, b) => + a.latest > b.latest ? -1 : a.latest < b.latest ? 1 : 0, + ) + let groupUnread = 0 + let groupTotal = 0 + let groupLatest = '' + for (const c of clusters) { + groupUnread += c.unread + groupTotal += c.items.length + if (c.latest > groupLatest) groupLatest = c.latest } - group.items.push(item) - if (item.updated_at > group.latest) group.latest = item.updated_at + out.push({ + repo, + clusters, + unread: groupUnread, + total: groupTotal, + latest: groupLatest, + }) } - return Array.from(map.values()).sort((a, b) => + return out.sort((a, b) => a.latest > b.latest ? -1 : a.latest < b.latest ? 1 : 0, ) }, [items]) + function toggleRepo(repo: string) { + setCollapsedRepos((prev) => { + const next = new Set(prev) + if (next.has(repo)) next.delete(repo) + else next.add(repo) + saveCollapsed(next) + return next + }) + } + + function toggleCluster(key: string) { + setExpandedClusters((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + async function handleSync() { setError(null) setSyncing(true) @@ -72,6 +176,23 @@ export function Inbox({ onSelectPr }: Props) { if (url) api.openUrl(url) } + async function handleClusterClick(cluster: Cluster) { + if (cluster.items.length > 1) { + toggleCluster(cluster.key) + return + } + await handleClick(cluster.representative) + } + + async function handleMarkRepoRead(repo: string) { + setError(null) + try { + await markRepoRead(repo) + } catch (e) { + setError(String(e)) + } + } + return (
@@ -137,22 +258,18 @@ export function Inbox({ onSelectPr }: Props) { )} {groups.length > 0 && ( -
+
{groups.map((group) => ( -
-

- {group.repo} -

-
    - {group.items.map((item) => ( - handleClick(item)} - /> - ))} -
-
+ toggleRepo(group.repo)} + onClusterClick={handleClusterClick} + onItemClick={handleClick} + onMarkRepoRead={() => handleMarkRepoRead(group.repo)} + /> ))}
)} @@ -161,29 +278,107 @@ export function Inbox({ onSelectPr }: Props) { ) } -function NotificationItem({ - item, +function RepoSection({ + group, + collapsed, + expandedClusters, + onToggleRepo, + onClusterClick, + onItemClick, + onMarkRepoRead, +}: { + group: Group + collapsed: boolean + expandedClusters: Set + onToggleRepo: () => void + onClusterClick: (cluster: Cluster) => void + onItemClick: (item: NotificationRow) => void + onMarkRepoRead: () => void +}) { + return ( +
+
+ + {group.unread > 0 && ( + + )} +
+ {!collapsed && ( +
    + {group.clusters.map((cluster) => ( + onClusterClick(cluster)} + onItemClick={onItemClick} + /> + ))} +
+ )} +
+ ) +} + +function ClusterRow({ + cluster, + expanded, onClick, + onItemClick, }: { - item: NotificationRow + cluster: Cluster + expanded: boolean onClick: () => void + onItemClick: (item: NotificationRow) => void }) { + const item = cluster.representative const Icon = iconForReason(item.reason) const externalUrl = htmlUrlFromSubject(item) + const collapsedCluster = cluster.items.length > 1 + const showUnreadDot = cluster.unread > 0 return ( -
  • +
  • + + {collapsedCluster && expanded && ( +
      + {cluster.items.map((sub) => ( + onItemClick(sub)} /> + ))} +
    + )} +
  • + ) +} + +function ClusterChild({ + item, + onClick, +}: { + item: NotificationRow + onClick: () => void +}) { + const externalUrl = htmlUrlFromSubject(item) + return ( +
  • +
  • ) } diff --git a/src/components/repo-list.tsx b/src/components/repo-list.tsx index 999b1f4..8d36aaa 100644 --- a/src/components/repo-list.tsx +++ b/src/components/repo-list.tsx @@ -9,6 +9,11 @@ import { import { AddRepoDialog } from '@/components/add-repo-dialog' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '@/components/ui/hover-card' import { Skeleton } from '@/components/ui/skeleton' import { api, type WatchedRepo } from '@/lib/api' @@ -114,25 +119,18 @@ export function RepoList({ if (collapsed) { return ( -