Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
73aede7
docs: add design spec for PR badge background prefetch/cache
Jul 9, 2026
fd17043
docs: add implementation plan for PR badge background prefetch/cache
Jul 9, 2026
650f3f1
refactor: extract whenIdle() into a shared idleSchedule util
Jul 9, 2026
9b08128
fix: drain open-PR pages in the background so branch badges aren't ca…
Jul 9, 2026
16051a9
fix: cancel in-flight PR prefetch on any cwd change, not just a fresh…
Jul 9, 2026
2116df6
feat(rust): add PrFreshnessSignal + REST freshness probe for PR badge…
Jul 9, 2026
943e61c
feat(rust): add gh_pr_freshness_signal Tauri command (gh CLI path)
Jul 9, 2026
e9f898a
feat: add ghPrFreshnessSignal TS wrapper for the PR badge cache
Jul 9, 2026
abd56ec
feat: add /api/gh-pr-freshness dev-server route
Jul 9, 2026
688c6c4
feat: cache drained open-PR lists and validate them via a cheap fresh…
Jul 9, 2026
75145e9
docs: add design spec for wiring the AppDock PR count badge
Jul 10, 2026
dc84432
docs: correct spec - AppDock pr-count is already wired, just to the w…
Jul 10, 2026
a1a3e71
docs: add implementation plan for wiring the AppDock PR count badge
Jul 10, 2026
58bd0fe
feat: add cheap dockPrCount refresh independent of the branch-badge list
Jul 10, 2026
6f5efba
fix: discard stale getPRCount results from a repo the user already na…
Jul 10, 2026
57ab78e
feat: show the true open-PR count on the dock badge, independent of t…
Jul 10, 2026
4e8a622
fix: seed remote from cache on repo switch so dockPrCount resolves th…
Jul 10, 2026
2673f3f
docs(roadmap): note 3 deferred follow-ups from the PR badge/dock-coun…
Jul 10, 2026
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
3 changes: 3 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ _Veille du 2026-06-24 sur 6 clients/outils (Snipara, GitDriv, GitUp, GitX-dev, A

- **In-app folder-browser + right-click "Scope here"** — follow-up to v2.21.0 Monorepo Scope: a recursive working-tree folder panel where right-clicking a folder scopes to it. Ad-hoc scoping already ships via the picker's "Custom folder…"; this in-tree gesture is deferred (the existing `FolderDiffTree` is a *diff* tree — the wrong substrate — and is unmounted).
- **Today Phase 3 — active mutations** — deferred remainder of the Today inbox (shipped v3.0.0): real nudge / auto-merge actions from the inbox, and a direct jump from "Resolve" into the conflict resolver (today routes to in-app PR review). Note: Phase 2's filter-chips + group-by model was superseded by the fixed-section inbox (see `useLaunchpadInbox.ts`).
- **Multi-forge PR-freshness signal parity** — follow-up to the branch-badge background-prefetch/cache work (PR #125): GitLab/Bitbucket/Azure already get the breadth fix (background drain past the first page), but not the cheap freshness-signal instant-cache-restore fast path — GitHub-only today, since the other three don't yet have an equivalent cheap "most-recently-updated PR" query built. Deferred until there's real non-GitHub usage pressure.
- **Cursor-based PR-list pagination (Rust)** — already flagged in-code as Phase 2/v2.9 (`gh_list_prs_inner`/`rest_list_prs` comments, predates PR #125): replace the naive offset+limit re-fetch, which re-walks from the start on every page, with a cursor-based `gh api graphql` query. Removes the quadratic re-fetch cost for repos with very large open-PR counts; most repos stay well under the current 300-PR prefetch ceiling, so this is scalability hardening, not an urgent fix.
- **Dock PR-count staleness indicator** — the app-dock "prs" badge (PR #125) refreshes only on repo-open and on the PR view's manual refresh, no periodic poll — a long-lived session can show a stale count if PRs open/close externally in the meantime. A lightweight visual "possibly stale" cue is preferred over polling, which would reopen the exact boot-perf risk (v2.8.5) this design was built to avoid.

---

Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2966,6 +2966,43 @@ async function handleRequest(req, res) {
}
}

// GET /api/gh-pr-freshness?cwd=<path>
// Cheap "has the open-PR list changed?" probe for the branch-badge
// background-drain cache (mirrors Rust `gh_pr_freshness_signal`): the
// single most-recently-updated open PR plus the open PR count. Returns
// null when the repo has zero open PRs or the token/remote can't be
// resolved — the frontend treats that the same as "cache stale, redrain".
if (url.pathname === "/api/gh-pr-freshness" && req.method === "GET") {
const cwd = url.searchParams.get("cwd");
if (!cwd) return jsonResponse(req, res, { error: "Missing cwd" }, 400);
try {
const token = getGithubToken();
if (!token) return jsonResponse(req, res, null);
const nwo = getRepoNwo(resolve(cwd));
if (!nwo) return jsonResponse(req, res, null);
const topResp = await githubFetch(`/repos/${nwo}/pulls?state=open&per_page=1&page=1&sort=updated&direction=desc`, token);
if (!topResp.ok) return jsonResponse(req, res, null);
const topData = await topResp.json();
if (!Array.isArray(topData) || topData.length === 0) return jsonResponse(req, res, null);
// Count via the same Link-header trick as /api/gh-pr-count.
const countResp = await githubFetch(`/repos/${nwo}/pulls?state=open&per_page=1`, token);
let openCount = topData.length;
if (countResp.ok) {
const linkHeader = countResp.headers.get("link") || "";
const m = linkHeader.match(/<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/);
if (m) openCount = parseInt(m[1], 10);
}
return jsonResponse(req, res, {
number: topData[0].number,
updated_at: topData[0].updated_at,
open_count: openCount,
});
} catch (err) {
console.error("[gh-pr-freshness]", err.message);
return jsonResponse(req, res, null);
}
}

// POST /api/gh-create-pr
// Body: { cwd, title, body, base?, head?, draft?, reviewers? }
// Creates the PR via REST, then requests reviewers in a second call
Expand Down Expand Up @@ -6198,6 +6235,7 @@ server.listen(PORT, "127.0.0.1", () => {
console.log(` GET /api/git-log?cwd=<path>&count=<n>&all=<bool>`);
console.log(` GET /api/gh-list-prs?cwd=<path>&state=<state>`);
console.log(` GET /api/gh-pr-count?cwd=<path>&state=<state>`);
console.log(` GET /api/gh-pr-freshness?cwd=<path>`);
console.log(` POST /api/gh-create-pr { cwd, title, body, base?, draft?, reviewers? }`);
console.log(` GET /api/gh-reviewer-candidates?cwd=<path>`);
console.log(` GET /api/gh-pr-detail?cwd=<path>&number=<n>`);
Expand Down
85 changes: 85 additions & 0 deletions apps/desktop/src-tauri/src/commands/gh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,65 @@ pub(crate) async fn gh_pr_count(cwd: String, state: String) -> Result<i64, Strin
.map_err(|e| e.to_string())?
}

/// Pure extraction of `(number, updated_at)` from `gh pr list --json
/// number,updatedAt` output (already `--limit 1 -S "sort:updated-desc"`
/// sorted) — the gh CLI's JSON keys are camelCase, unlike the REST API's
/// snake_case, so this can't share `top_pr_from_rest_json` in github_api.rs.
fn top_pr_from_cli_json(raw: &[serde_json::Value]) -> Option<(i64, String)> {
let top = raw.first()?;
let updated_at = top.get("updatedAt").and_then(|v| v.as_str())?.to_string();
let number = top.get("number").and_then(|v| v.as_i64())?;
Some((number, updated_at))
}

fn gh_pr_freshness_signal_inner(cwd: String) -> Result<Option<PrFreshnessSignal>, String> {
if let Some(tok) = github_api::settings_github_token() {
return github_api::rest_pr_freshness_signal(&cwd, &tok);
}
// No sort flag is used by the main gh_list_prs_inner list fetch (its
// order doesn't matter there) — this probe needs updated-desc
// specifically, via a `-S` search-query sort, requested only here.
let target_repo = gh_fork_upstream(&cwd);
let mut cmd = hidden_cmd("gh");
cmd.args([
"pr", "list",
"--state", "open",
"--json", "number,updatedAt",
"--limit", "1",
"-S", "sort:updated-desc",
]);
if let Some(ref nwo) = target_repo {
cmd.args(["--repo", nwo]);
}
let output = cmd
.current_dir(&cwd)
.output()
.map_err(|e| format!("Failed to run gh pr list (freshness signal): {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("gh pr list (freshness signal) failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let raw: Vec<serde_json::Value> = serde_json::from_str(stdout.trim())
.map_err(|e| format!("Failed to parse freshness signal output: {}", e))?;
let (number, updated_at) = match top_pr_from_cli_json(&raw) {
Some(pair) => pair,
None => return Ok(None),
};
let open_count = gh_pr_count_inner(cwd, "open".to_string())?;
Ok(Some(PrFreshnessSignal { number, updated_at, open_count }))
}

/// Cheap "has the open-PR list changed?" probe for the branch-badge
/// background-drain cache (`usePrPanel.ts`'s `PR_LIST_CACHE`). See
/// `PrFreshnessSignal`'s doc comment for why two facts are needed.
#[tauri::command]
pub(crate) async fn gh_pr_freshness_signal(cwd: String) -> Result<Option<PrFreshnessSignal>, String> {
tauri::async_runtime::spawn_blocking(move || gh_pr_freshness_signal_inner(cwd))
.await
.map_err(|e| e.to_string())?
}

fn gh_create_pr_inner(
cwd: String,
title: String,
Expand Down Expand Up @@ -1399,3 +1458,29 @@ mod gh_list_issues_tests {
assert_eq!(issues[0].milestone, "v1");
}
}

#[cfg(test)]
mod gh_pr_freshness_tests {
use super::*;
use serde_json::json;

#[test]
fn top_pr_from_cli_json_reads_first_entry() {
let raw = vec![json!({"number": 12, "updatedAt": "2026-07-09T09:00:00Z"})];
assert_eq!(
top_pr_from_cli_json(&raw),
Some((12, "2026-07-09T09:00:00Z".to_string()))
);
}

#[test]
fn top_pr_from_cli_json_empty_list_is_none() {
assert_eq!(top_pr_from_cli_json(&[]), None);
}

#[test]
fn top_pr_from_cli_json_missing_updated_at_is_none() {
let raw = vec![json!({"number": 12})];
assert_eq!(top_pr_from_cli_json(&raw), None);
}
}
59 changes: 59 additions & 0 deletions apps/desktop/src-tauri/src/commands/github_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,48 @@ pub(crate) fn rest_list_prs(
Ok(prs)
}

/// Pure extraction of `(number, updated_at)` from a REST `GET .../pulls` JSON
/// array already sorted `updated desc` — the first element is the signal.
/// Split out from `rest_pr_freshness_signal` so it's unit-testable without an
/// HTTP call (mirrors `json_to_pr`'s separation of parsing from I/O).
fn top_pr_from_rest_json(raw: &[serde_json::Value]) -> Option<(i64, String)> {
let top = raw.first()?;
let updated_at = js(top, "updated_at");
if updated_at.is_empty() {
return None;
}
Some((ji(top, "number"), updated_at))
}

/// Cheap "has the open-PR list changed?" probe: the single most-recently
/// updated open PR (`per_page=1&sort=updated&direction=desc`, no per-PR
/// enrichment — unlike `rest_list_prs` this never calls the detail/check-runs
/// endpoints) plus the open PR count (`rest_pr_count`, already a single
/// `/search/issues` call). `Ok(None)` means the repo currently has zero open
/// PRs.
pub(crate) fn rest_pr_freshness_signal(cwd: &str, token: &str) -> Result<Option<PrFreshnessSignal>, String> {
let base = base_owner_repo(cwd, token).unwrap_or_else(|_| {
let (o, r) = owner_repo(cwd).unwrap_or_default();
format!("{}/{}", o, r)
});
let raw: Vec<serde_json::Value> = api_json_cached(
&format!(
"{}/repos/{}/pulls?state=open&per_page=1&page=1&sort=updated&direction=desc",
API_BASE, base
),
token,
)?
.as_array()
.cloned()
.unwrap_or_default();
let (number, updated_at) = match top_pr_from_rest_json(&raw) {
Some(pair) => pair,
None => return Ok(None),
};
let open_count = rest_pr_count(cwd, "open", token)?;
Ok(Some(PrFreshnessSignal { number, updated_at, open_count }))
}

/// Aggregate the check-runs of commit `sha` in `repo` ("owner/name") into a
/// single rollup state: `FAILURE` / `PENDING` / `SUCCESS`, or `""` when the
/// commit has no checks configured. Best effort — any HTTP error yields `""`.
Expand Down Expand Up @@ -1619,4 +1661,21 @@ mod tests {
"header = \"Authorization: Bearer ghp_abc123\"\n"
);
}

#[test]
fn top_pr_from_rest_json_reads_first_sorted_entry() {
let raw = vec![
json!({"number": 88, "updated_at": "2026-07-09T10:00:00Z"}),
json!({"number": 3, "updated_at": "2026-01-01T00:00:00Z"}),
];
assert_eq!(
top_pr_from_rest_json(&raw),
Some((88, "2026-07-09T10:00:00Z".to_string()))
);
}

#[test]
fn top_pr_from_rest_json_empty_list_is_none() {
assert_eq!(top_pr_from_rest_json(&[]), None);
}
}
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ pub fn run() {
commands::ops::git_remote_info,
commands::gh::gh_list_prs,
commands::gh::gh_pr_count,
commands::gh::gh_pr_freshness_signal,
commands::gh::gh_create_pr,
commands::gh::gh_list_reviewer_candidates,
commands::gh::gh_branches,
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,20 @@ pub struct PullRequest {
pub comment_count: i64,
}

/// Cheap signal used to detect whether a repo's open-PR list has changed
/// since it was last fully drained and cached (`usePrPanel.ts`'s
/// `PR_LIST_CACHE`), without re-fetching the whole list. Two facts, since
/// neither alone catches every kind of change: the most-recently-updated
/// open PR (catches edits/comments/new PRs) and the open PR count (catches
/// a PR closing, which drops it out of the "state=open" results entirely
/// without changing who's "most recently updated").
#[derive(Serialize)]
pub struct PrFreshnessSignal {
pub number: i64,
pub updated_at: String,
pub open_count: i64,
}

#[derive(Deserialize)]
pub struct GhPrAuthor {
/// `None` when the original author has been deleted on GitHub, or for
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3344,7 +3344,7 @@ onUnmounted(() => {

<!-- Floating bottom-center navigation dock -->
<AppDock v-if="hasRepo" :view-mode="viewMode" :changes-count="repoFiles.length"
:pr-count="prPanel.prs.value.length" :terminal-active="showTerminal" :files-active="showFiles"
:pr-count="prPanel.dockPrCount.value ?? undefined" :terminal-active="showTerminal" :files-active="showFiles"
@change-view="onViewModeChange" @toggle-terminal="toggleTerminal()" @toggle-files="toggleFiles()" />
</div>

Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/components/PrListSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const { t } = useI18n();
const panel = inject<PrPanelState>(PR_PANEL_KEY)!;
const openSettings = inject(OPEN_SETTINGS_KEY, undefined);

/** Manual refresh: reload both the PR list and the dock's total-count badge together. */
function refreshList() {
panel.loadPrs();
panel.refreshDockPrCount();
}

// ─── Lazy pagination sentinel (v2.8.5 §E) ─────────────────
// IntersectionObserver watches a 1px element at the bottom of the list;
// when it enters the viewport, we ask the panel for the next page.
Expand Down Expand Up @@ -145,7 +151,7 @@ function setUserFilter(mode: 'all' | 'assigned' | 'reviews') {
<button
v-else
class="pls-icon-btn"
@click="panel.loadPrs"
@click="refreshList"
:title="t('pr.list.refresh')"
aria-label="Refresh"
>
Expand Down
Loading
Loading