diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7838d7d..70ff69a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -299,6 +299,14 @@ fn handle_menu_event(app: &tauri::AppHandle, id: &str) { }); } + "open_panel" => { + let _ = app.emit("show-merged", ()); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "settings" => { let _ = app.emit("open-settings", ()); if let Some(window) = app.get_webview_window("main") { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 7db93ca..b1fa91a 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1,5 +1,5 @@ use tauri::AppHandle; -use tauri::menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem}; use crate::avatars::AvatarCache; use crate::models::{CheckStatus, PrState, PullRequest}; @@ -10,7 +10,7 @@ struct PrSection { default_collapsed: bool, } -/// Port of src/lib/stores.ts groupPrs() with tray-specific section ordering. +/// Port of src/lib/stores.ts groupPrs() matching its section ordering. fn group_prs(all_prs: &[PullRequest]) -> Vec { let review_requested: Vec<_> = all_prs .iter() @@ -80,16 +80,47 @@ fn group_prs(all_prs: &[PullRequest]) -> Vec { .collect(), default_collapsed: false, }, + PrSection { + title: "Mergeable".into(), + prs: non_draft_open + .iter() + .filter(|pr| { + pr.merge_queue_info.is_none() + && pr.check_status == CheckStatus::Success + && pr.review_decision.as_deref() != Some("CHANGES_REQUESTED") + && pr.review_decision.as_deref() != Some("APPROVED") + && (pr.review_decision.as_deref() == Some("REVIEW_REQUIRED") + || pr.review_decision.is_none()) + }) + .cloned() + .collect(), + default_collapsed: false, + }, + PrSection { + title: "Checks Running".into(), + prs: non_draft_open + .iter() + .filter(|pr| { + pr.merge_queue_info.is_none() + && pr.check_status == CheckStatus::Pending + && pr.review_decision.as_deref() != Some("CHANGES_REQUESTED") + && pr.review_decision.as_deref() != Some("APPROVED") + }) + .cloned() + .collect(), + default_collapsed: false, + }, PrSection { title: "Waiting for Review".into(), prs: non_draft_open .iter() .filter(|pr| { pr.merge_queue_info.is_none() - && pr.check_status != CheckStatus::Failure - && pr.check_status != CheckStatus::Error + && pr.check_status == CheckStatus::None && pr.review_decision.as_deref() != Some("CHANGES_REQUESTED") && pr.review_decision.as_deref() != Some("APPROVED") + && (pr.review_decision.as_deref() == Some("REVIEW_REQUIRED") + || pr.review_decision.is_none()) }) .cloned() .collect(), @@ -148,37 +179,9 @@ pub fn build_pr_menu( first = false; if section.default_collapsed { - let sub_title = format!("{} ({})", section.title, section.prs.len()); - let submenu = Submenu::with_id( - app, - &format!("section_{}", section.title), - &sub_title, - true, - )?; - - let show_count = section.prs.len().min(5); - for pr in §ion.prs[..show_count] { - let age = time_ago(&pr.created_at); - let label = format_pr_label(pr, &age); - - let icon = avatar_cache.get_image(&pr.author_login); - let item_id = if section.title == "Checks Failing" { - format!("pr_checks_{}", pr.id) - } else { - format!("pr_{}", pr.id) - }; - let item = IconMenuItem::with_id( - app, - &item_id, - &label, - true, - icon, - None::<&str>, - )?; - submenu.append(&item)?; - } - - menu.append(&submenu)?; + let item_text = format!("{} ({})", section.title, section.prs.len()); + let item = MenuItem::with_id(app, "open_panel", &item_text, true, None::<&str>)?; + menu.append(&item)?; } else { // Section header (disabled) let header_text = format!("{} ({})", section.title, section.prs.len()); diff --git a/src-tauri/src/poller.rs b/src-tauri/src/poller.rs index 05eb859..08fef41 100644 --- a/src-tauri/src/poller.rs +++ b/src-tauri/src/poller.rs @@ -44,22 +44,6 @@ pub fn start_polling(app_handle: AppHandle) { diff_pr_states(&previous, &new_prs) }; - // Send notifications for state changes (gated by user settings) - { - let settings = app_handle.state::().settings.lock().unwrap().clone(); - for event in &events { - let should_notify = match event { - PrEvent::ChecksFailed(_) => settings.notify_checks_failed, - PrEvent::ChecksPassed(_) => settings.notify_checks_passed, - PrEvent::Merged(_) => settings.notify_merged, - PrEvent::RemovedFromMergeQueue(_) => settings.notify_removed_from_queue, - }; - if should_notify { - send_notification(&app_handle, event); - } - } - } - // Update stored state { let state = app_handle.state::(); @@ -76,6 +60,25 @@ pub fn start_polling(app_handle: AppHandle) { *last_poll = Some(chrono::Utc::now()); } + // Emit to frontend webview + let _ = app_handle.emit("prs-updated", &new_prs); + + // Send notifications for state changes (gated by user settings) + { + let settings = app_handle.state::().settings.lock().unwrap().clone(); + for event in &events { + let should_notify = match event { + PrEvent::ChecksFailed(_) => settings.notify_checks_failed, + PrEvent::ChecksPassed(_) => settings.notify_checks_passed, + PrEvent::Merged(_) => settings.notify_merged, + PrEvent::RemovedFromMergeQueue(_) => settings.notify_removed_from_queue, + }; + if should_notify { + send_notification(&app_handle, event); + } + } + } + // Fetch avatars for any new authors app_handle .state::() @@ -97,9 +100,6 @@ pub fn start_polling(app_handle: AppHandle) { } } - // Emit to frontend webview - let _ = app_handle.emit("prs-updated", &new_prs); - // Adaptive sleep let interval = if has_active_items(&new_prs) { POLL_INTERVAL_ACTIVE diff --git a/src/App.svelte b/src/App.svelte index 1b56da0..e60e8dd 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,6 +8,7 @@ import SettingsPage from "./lib/SettingsPage.svelte"; import UpdateDialog from "./lib/UpdateDialog.svelte"; import TitleBar from "./lib/TitleBar.svelte"; + import { RECENTLY_MERGED_SECTION_TITLE } from "./lib/constants"; import "./lib/theme.svelte.ts"; const isUpdaterWindow = @@ -20,6 +21,7 @@ let refreshing = $state(false); let checkingAuth = $state(true); let view = $state<"panel" | "settings">("panel"); + let focusSection = $state(null); let settings = $state({ notify_checks_failed: true, notify_checks_passed: true, @@ -31,6 +33,7 @@ let unlisten: (() => void) | undefined; let unlistenAuth: (() => void) | undefined; let unlistenSettings: (() => void) | undefined; + let unlistenShowMerged: (() => void) | undefined; async function init() { // Load settings independently — they're local and should not fail with network errors @@ -92,6 +95,7 @@ prList = []; userInfo = null; lastUpdated = null; + focusSection = null; } catch (e) { console.error("[app] Logout failed:", e); } @@ -130,6 +134,7 @@ prList = []; userInfo = null; lastUpdated = null; + focusSection = null; }); unlistenSettings = await listen("open-settings", async () => { // Re-check auth: user may have signed in via tray menu @@ -143,8 +148,13 @@ console.error("[app] Re-auth check failed:", e); } } + focusSection = null; view = "settings"; }); + unlistenShowMerged = await listen("show-merged", () => { + view = "panel"; + focusSection = RECENTLY_MERGED_SECTION_TITLE; + }); await init(); } @@ -156,6 +166,7 @@ unlisten?.(); unlistenAuth?.(); unlistenSettings?.(); + unlistenShowMerged?.(); }); @@ -187,7 +198,9 @@ {refreshing} onRefresh={handleRefresh} onLogout={handleLogout} - onOpenSettings={() => view = "settings"} + onOpenSettings={() => { focusSection = null; view = "settings"; }} + {focusSection} + onClearFocus={() => { focusSection = null; }} /> {/if} diff --git a/src/App.test.ts b/src/App.test.ts new file mode 100644 index 0000000..ee88b85 --- /dev/null +++ b/src/App.test.ts @@ -0,0 +1,125 @@ +import { render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +// @ts-expect-error test helpers are exposed by the aliased mock module +import { __setInvokeHandler, __resetInvokeMock } from "@tauri-apps/api/core"; +// @ts-expect-error test helpers are exposed by the aliased mock module +import { __triggerEvent, __resetListeners } from "@tauri-apps/api/event"; + + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); +}); +afterEach(() => { + __resetInvokeMock(); + __resetListeners(); +}); + +function setupInvokeDefaults() { + __setInvokeHandler((cmd: string) => { + switch (cmd) { + case "is_authenticated_cmd": + return true; + case "refresh_prs_cmd": + case "get_pull_requests_cmd": + return [ + { + id: "PR_1", + number: 42, + title: "feat: merged PR", + url: "https://github.com/test/repo/pull/42", + state: "merged", + repository: "repo", + owner: "test", + head_ref: "feat-x", + base_ref: "main", + check_status: "success", + is_draft: false, + labels: [], + merge_queue_info: null, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + review_decision: null, + additions: 10, + deletions: 3, + comment_count: 0, + author_login: "testuser", + author_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", + is_review_requested: false, + }, + { + id: "PR_2", + number: 43, + title: "feat: open PR", + url: "https://github.com/test/repo/pull/43", + state: "open", + repository: "repo", + owner: "test", + head_ref: "feat-y", + base_ref: "main", + check_status: "success", + is_draft: false, + labels: [], + merge_queue_info: null, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + review_decision: "APPROVED", + additions: 5, + deletions: 1, + comment_count: 2, + author_login: "testuser", + author_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", + is_review_requested: false, + }, + ]; + case "get_user_info_cmd": + return { + login: "testuser", + avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", + name: "Test User", + }; + case "get_settings_cmd": + return { + notify_checks_failed: true, + notify_checks_passed: true, + notify_merged: true, + notify_removed_from_queue: true, + hidden_repos: [], + }; + default: + return null; + } + }); +} + +describe("App — show-merged event", () => { + it("focuses the Recently Merged section when show-merged fires", async () => { + setupInvokeDefaults(); + + const { default: App } = await import("./App.svelte"); + render(App); + + await waitFor(() => { + expect(screen.getByText("Recently Merged")).toBeTruthy(); + }); + + expect(screen.getByText("Approved")).toBeTruthy(); + expect(screen.queryByText("← All PRs")).toBeFalsy(); + + __triggerEvent("show-merged"); + + await waitFor(() => { + expect(screen.getByText("← All PRs")).toBeTruthy(); + }); + + expect(screen.getByText("Recently Merged")).toBeTruthy(); + expect(screen.queryByText("Approved")).toBeFalsy(); + }); +}); diff --git a/src/__mocks__/tauri-api.ts b/src/__mocks__/tauri-api.ts index daeec0b..67f7b60 100644 --- a/src/__mocks__/tauri-api.ts +++ b/src/__mocks__/tauri-api.ts @@ -1,4 +1,20 @@ // Stub for @tauri-apps/api/core — returns sensible defaults so components mount -export async function invoke(_cmd: string, _args?: unknown): Promise { +let invokeHandler: ((cmd: string, args?: unknown) => unknown) | null = null; + +export async function invoke(cmd: string, args?: unknown): Promise { + if (invokeHandler) { + return invokeHandler(cmd, args); + } + return null; } + +export function __setInvokeHandler( + handler: (cmd: string, args?: unknown) => unknown, +): void { + invokeHandler = handler; +} + +export function __resetInvokeMock(): void { + invokeHandler = null; +} diff --git a/src/__mocks__/tauri-event.ts b/src/__mocks__/tauri-event.ts index c048e2c..00cdbf9 100644 --- a/src/__mocks__/tauri-event.ts +++ b/src/__mocks__/tauri-event.ts @@ -1,8 +1,56 @@ // Stub for @tauri-apps/api/event +const listeners = new Map void>>(); + +function notifyListeners(event: string, payload?: unknown): void { + const eventListeners = listeners.get(event); + + if (!eventListeners) { + return; + } + + const tauriEvent = { + event, + payload, + id: 0, + windowLabel: "main", + }; + + for (const handler of eventListeners) { + handler(tauriEvent); + } +} + export async function listen( - _event: string, - _handler: (...args: unknown[]) => void, + event: string, + handler: (...args: unknown[]) => void, ): Promise<() => void> { - return () => {}; + const eventListeners = listeners.get(event) ?? new Set(); + eventListeners.add(handler); + listeners.set(event, eventListeners); + + return () => { + const registeredHandlers = listeners.get(event); + + if (!registeredHandlers) { + return; + } + + registeredHandlers.delete(handler); + + if (registeredHandlers.size === 0) { + listeners.delete(event); + } + }; +} + +export async function emit(event: string, payload?: unknown): Promise { + notifyListeners(event, payload); +} + +export function __triggerEvent(eventName: string, payload?: unknown): void { + notifyListeners(eventName, payload); +} + +export function __resetListeners(): void { + listeners.clear(); } -export async function emit(_event: string, _payload?: unknown): Promise {} diff --git a/src/lib/PRPanel.svelte b/src/lib/PRPanel.svelte index 4048d78..5a1b3aa 100644 --- a/src/lib/PRPanel.svelte +++ b/src/lib/PRPanel.svelte @@ -18,8 +18,20 @@ onRefresh: () => void; onLogout: () => void; onOpenSettings: () => void; + focusSection?: string | null; + onClearFocus?: (() => void) | null; } - let { prs, user, lastUpdated, refreshing, onRefresh, onLogout, onOpenSettings }: Props = $props(); + let { + prs, + user, + lastUpdated, + refreshing, + onRefresh, + onLogout, + onOpenSettings, + focusSection = null, + onClearFocus = null, + }: Props = $props(); let now = $state(new Date()); $effect(() => { @@ -27,7 +39,12 @@ return () => clearInterval(id); }); - let sections = $derived(groupPrs(prs)); + let allSections = $derived(groupPrs(prs)); + let visibleSections = $derived( + focusSection + ? allSections.filter(s => s.title === focusSection) + : allSections, + ); let totalPrs = $derived(prs.filter(pr => pr.state === "open").length); function relativeTime(date: Date | null): string { @@ -75,6 +92,14 @@ +{#if focusSection} + +{/if}
{#if prs.length === 0}
@@ -82,15 +107,15 @@

No pull requests found

Your PRs will appear here

- {:else if sections.length === 0} + {:else if visibleSections.length === 0}

All clear!

{:else}
- {#each sections as section (section.title)} - + {#each visibleSections as section (section.title)} + {/each}
{/if} diff --git a/src/lib/PRSection.svelte b/src/lib/PRSection.svelte index ab71e46..c1b7690 100644 --- a/src/lib/PRSection.svelte +++ b/src/lib/PRSection.svelte @@ -4,14 +4,19 @@ interface Props { section: PrSection; + expandAll?: boolean; } - let { section }: Props = $props(); + let { section, expandAll = false }: Props = $props(); - let collapsed = $state(section.defaultCollapsed ?? false); + const getInitialCollapsed = () => section.defaultCollapsed ?? false; + let collapsed = $state(getInitialCollapsed()); let visibleCount = $state(5); let SectionIcon = $derived(section.icon); let visiblePrs = $derived(section.prs.slice(0, visibleCount)); let hasMore = $derived(visibleCount < section.prs.length); + let effectiveCollapsed = $derived(expandAll ? false : collapsed); + let effectiveVisiblePrs = $derived(expandAll ? section.prs : visiblePrs); + let effectiveHasMore = $derived(expandAll ? false : hasMore); function destinationUrlFor(pr: (typeof section.prs)[number]): string { return section.title === "Checks Failing" ? `${pr.url}/checks` : pr.url; @@ -32,20 +37,20 @@
- {#if !collapsed} + {#if !effectiveCollapsed}
- {#each visiblePrs as pr (pr.id)} + {#each effectiveVisiblePrs as pr (pr.id)} {/each} - {#if hasMore} + {#if effectiveHasMore}