Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
73 changes: 38 additions & 35 deletions src-tauri/src/menu.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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<PrSection> {
let review_requested: Vec<_> = all_prs
.iter()
Expand Down Expand Up @@ -80,16 +80,47 @@ fn group_prs(all_prs: &[PullRequest]) -> Vec<PrSection> {
.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(),
Expand Down Expand Up @@ -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 &section.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());
Expand Down
38 changes: 19 additions & 19 deletions src-tauri/src/poller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<AppState>().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::<AppState>();
Expand All @@ -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::<AppState>().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::<AppState>()
Expand All @@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -20,6 +21,7 @@
let refreshing = $state(false);
let checkingAuth = $state(true);
let view = $state<"panel" | "settings">("panel");
let focusSection = $state<string | null>(null);
let settings = $state<UserSettings>({
notify_checks_failed: true,
notify_checks_passed: true,
Expand All @@ -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
Expand Down Expand Up @@ -92,6 +95,7 @@
prList = [];
userInfo = null;
lastUpdated = null;
focusSection = null;
} catch (e) {
console.error("[app] Logout failed:", e);
}
Expand Down Expand Up @@ -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
Expand All @@ -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();
}

Expand All @@ -156,6 +166,7 @@
unlisten?.();
unlistenAuth?.();
unlistenSettings?.();
unlistenShowMerged?.();
});
</script>

Expand Down Expand Up @@ -187,7 +198,9 @@
{refreshing}
onRefresh={handleRefresh}
onLogout={handleLogout}
onOpenSettings={() => view = "settings"}
onOpenSettings={() => { focusSection = null; view = "settings"; }}
{focusSection}
onClearFocus={() => { focusSection = null; }}
/>
{/if}
</main>
Expand Down
125 changes: 125 additions & 0 deletions src/App.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading