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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The app made for people who review multiple Pull Requests every day.
[![Publish npm package](https://github.com/felipe-software/pr-run/actions/workflows/publish-npm.yml/badge.svg)](https://github.com/felipe-software/pr-run/actions/workflows/publish-npm.yml)
[![Version Bump](https://github.com/felipe-software/pr-run/actions/workflows/version-bump.yaml/badge.svg)](https://github.com/felipe-software/pr-run/actions/workflows/version-bump.yaml)
<br>

## Run

PR-run requires [Bun](https://bun.sh/docs/installation).
Expand Down
282 changes: 175 additions & 107 deletions bun.lock

Large diffs are not rendered by default.

78 changes: 50 additions & 28 deletions docs/SIDEBAR_ITEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,52 +5,74 @@ Sidebar branch items are split into small components under

- `sidebar-branch-item.tsx` renders the clickable row, branch name, status pill,
last commit age, and worktree removal action.
- `sidebar-item-icon.tsx` renders the branch or pull request icon.
- `sidebar-pr-people-tooltip.tsx` renders a PR author's avatar and the compact
review-workflow tooltip.
- `sidebar-pr-people.ts` merges requested reviewers, latest reviewers, and
assignees into the tooltip's related-people list.
- `src/lib/components/atoms/worktree-indicator.tsx` provides the shared
icon-only and labeled Git worktree indicator used across the application.
- `sidebar-item-status.ts` classifies each branch item and stores the sidebar
label, icon color classes, and pill color classes in one place.
label and pill color classes in one place.
- `sidebar-project-item.tsx` groups branch items by project and decides which
stale items are visible.

## Classification

`getSidebarItemStatus` classifies a `BranchInfo` using this priority order:

1. `stale-worktree`: `branch.hasWorktree === true` and
`branch.isStale === true`.
- Label: `Stale Worktree`
- Color: danger/red
- Reason: this branch has a local worktree, but the backing branch is stale,
so it needs the strongest warning.
2. `worktree`: `branch.hasWorktree === true`.
- Label: `Worktree`
1. `draft`: the branch belongs to an open draft pull request.
- Label: `Draft`
- Color: muted/neutral
2. `open`: the branch belongs to an open, review-ready pull request.
- Label: `Open`
- Color: success/green
- Reason: this branch has a local runnable worktree.
3. `stale`: `branch.isStale === true`.
3. `closed`: the branch belongs to a closed, unmerged pull request.
- Label: `Closed`
- Color: danger/red
4. `merged`: the branch belongs to a merged pull request.
- Label: `Merged`
- Color: done/violet
5. `stale`: a non-PR branch where `branch.isStale === true`.
- Label: `Stale`
- Color: warning/yellow
- Reason: this branch is stale but does not have a local worktree.
4. `pull-request`: `branch.source === "pull-request"`.
- Label: `PR`
- Color: blue
- Reason: this item came from an open pull request.
5. `branch`: fallback for every other branch.
6. `branch`: fallback for every other non-PR branch.
- Label: `Branch`
- Color: muted
- Reason: this is a regular branch without a worktree, stale marker, or pull
request source.

The priority order matters. For example, a pull request that is stale is shown
as `Stale`, and a stale branch with a worktree is shown as `Stale Worktree`.
Pull request state always owns the pill. Worktree and stale metadata never
replace `Draft`, `Open`, `Closed`, or `Merged`.

Open pull requests are always listed, preserving the previous behavior.
Historical closed and merged pull requests enrich only branches that still
exist on `origin`, so deleted PR head branches do not create unusable rows.

## Identity and State

The leading position answers who is responsible for a pull request:

- Pull request rows show the PR author's GitHub avatar.
- Hovering or focusing a PR row shows requested reviewers, latest review
states, and assignees.
- Plain branch and worktree rows do not reserve an identity slot because Git
does not provide a truthful branch owner.
- Non-PR branch names use reduced opacity at rest and return to full foreground
on hover, keyboard focus, or selection.
- Busy terminal state is shown beside the status pill instead of being attached
to the identity area.

Worktree presence is separate from the status pill. A compact green square with
a filled tree icon appears immediately before the status pill. The marker is
visual-only; the row's accessible label includes the worktree state.
Hovering the marker or status pill shows its meaning after a short delay.

The connector lands on the first meaningful content in each row: the author
avatar for a pull request, or the branch name for every other branch.

## Color Source

Sidebar item colors should be changed only in `sidebar-item-status.ts`.

Both `SidebarBranchItem` and `SidebarItemIcon` consume the same status config:

- `pillClassName` controls the status pill color.
- `iconClassName` controls the icon background and icon color.

This keeps the icon and status pill visually aligned for combined states such as
`Stale Worktree`. Sidebar pills use the `custom` `StatusPill` tone so the
sidebar status config fully owns the background, border, and text colors.
Sidebar pills use the `custom` `StatusPill` tone so the sidebar status config
fully owns the background, border, and text colors.
44 changes: 42 additions & 2 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { app, BrowserWindow, ipcMain, Menu, shell } from "electron";
import {
app,
BrowserWindow,
ipcMain,
Menu,
nativeTheme,
shell,
} from "electron";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import net from "node:net";
import path from "node:path";

import { TerminalSessionManager } from "./terminal-session-manager.js";
import type { TerminalCreateOptions, TerminalInputOptions } from "./types.js";
import {
getWindowChromeBackground,
getWindowChromeOptions,
type WindowTheme,
} from "./window-chrome.js";

const projectRoot = path.join(__dirname, "..");

Expand Down Expand Up @@ -192,14 +204,18 @@ async function loadRenderer(mainWindow: BrowserWindow, rendererUrl: string) {
}

async function createWindow() {
const initialTheme: WindowTheme = nativeTheme.shouldUseDarkColors
? "dark"
: "light";
const mainWindow = new BrowserWindow({
width: 1180,
height: 780,
minWidth: 920,
minHeight: 620,
title: "PR Run",
autoHideMenuBar: true,
backgroundColor: "#ffffff",
backgroundColor: getWindowChromeBackground(initialTheme),
...getWindowChromeOptions(process.platform, initialTheme),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
Expand Down Expand Up @@ -288,6 +304,30 @@ app.whenReady()

return backendUrl;
});
ipcMain.handle(
"window:setTitleBarTheme",
(event, theme: WindowTheme) => {
const window = BrowserWindow.fromWebContents(event.sender);

if (!window || window.isDestroyed()) {
return;
}

const resolvedTheme = theme === "light" ? "light" : "dark";
window.setBackgroundColor(
getWindowChromeBackground(resolvedTheme),
);

const { titleBarOverlay } = getWindowChromeOptions(
process.platform,
resolvedTheme,
);

if (typeof titleBarOverlay === "object") {
window.setTitleBarOverlay(titleBarOverlay);
}
},
);

ipcMain.handle(
"terminal:create",
Expand Down
7 changes: 7 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ async function requestMany<T>(path: string, init?: RequestInit): Promise<T[]> {
}

contextBridge.exposeInMainWorld("prRun", {
platform: process.platform,
setTitleBarTheme(theme: "dark" | "light") {
return ipcRenderer.invoke(
"window:setTitleBarTheme",
theme,
) as Promise<void>;
},
async getBackendUrl() {
return getBackendUrl();
},
Expand Down
24 changes: 24 additions & 0 deletions electron/window-chrome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";

import {
getWindowChromeOptions,
WORKSPACE_TITLEBAR_HEIGHT,
} from "./window-chrome";

describe("getWindowChromeOptions", () => {
test("uses the renderer-owned overlay outside macOS", () => {
expect(getWindowChromeOptions("win32", "dark")).toMatchObject({
titleBarOverlay: {
height: WORKSPACE_TITLEBAR_HEIGHT,
},
titleBarStyle: "hidden",
});
});

test("keeps macOS traffic lights in the inset title bar", () => {
expect(getWindowChromeOptions("darwin", "light")).toMatchObject({
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 13 },
});
});
});
44 changes: 44 additions & 0 deletions electron/window-chrome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { BrowserWindowConstructorOptions } from "electron";

export const WORKSPACE_TITLEBAR_HEIGHT = 36;

export type WindowTheme = "dark" | "light";

const windowColors = {
dark: {
background: "#17191c",
symbol: "#e8eaed",
},
light: {
background: "#f5f6f7",
symbol: "#25272a",
},
} as const;

export function getWindowChromeOptions(
platform: NodeJS.Platform,
theme: WindowTheme,
): Pick<
BrowserWindowConstructorOptions,
"titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
> {
if (platform === "darwin") {
return {
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 13 },
};
}

return {
titleBarOverlay: {
color: windowColors[theme].background,
height: WORKSPACE_TITLEBAR_HEIGHT,
symbolColor: windowColors[theme].symbol,
},
titleBarStyle: "hidden",
};
}

export function getWindowChromeBackground(theme: WindowTheme) {
return windowColors[theme].background;
}
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,15 @@
"typecheck": "tsc -b"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@elysiajs/cors": "^1.4.2",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@heroicons/react": "^2.2.0",
"@heroui/react": "^3.1.0",
"@heroui/styles": "^3.1.0",
"@pierre/diffs": "^1.2.10",
"@tanstack/react-query": "^5.101.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"effect": "^3.21.3",
"elysia": "^1.4.28",
"ky": "^2.0.2",
Expand All @@ -66,6 +65,11 @@
"pino-pretty": "^13.1.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
Expand Down
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useEffect } from "react";

import { PrRunApp } from "@/lib/components/templates/pr-run-app";
import { syncWindowControlsOverlayClass } from "@/lib/window-controls-overlay";

function App() {
useEffect(() => syncWindowControlsOverlayClass(), []);

return <PrRunApp />;
}

Expand Down
18 changes: 18 additions & 0 deletions src/backend/handlers/git/activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";

import { compareActivityItems } from "@/backend/handlers/git/activity";
import type { WorktreeActivityItem } from "@/backend/types";

describe("compareActivityItems", () => {
test("sorts oldest activity first with stable ids", () => {
const items = [
{ id: "b", occurredAt: "2026-07-10T00:00:00Z" },
{ id: "c", occurredAt: "2026-07-09T00:00:00Z" },
{ id: "a", occurredAt: "2026-07-10T00:00:00Z" },
] as WorktreeActivityItem[];

expect(items.sort(compareActivityItems).map((item) => item.id)).toEqual(
["c", "a", "b"],
);
});
});
Loading
Loading