From 8f5a45ca2a1935adb6f9a6901f15762c7e82f7b1 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 13 Jul 2026 17:37:58 -0700 Subject: [PATCH] =?UTF-8?q?Add=20pr-cockpit=20=E2=80=94=20Electron=20deskt?= =?UTF-8?q?op=20grid=20of=20live=20GitHub=20PR=20webviews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone dev tool: tiles open PRs (via gh pr list) as real browser contexts with review-priority sort, settledness pills, per-status hue injection, auto light/dark, focus mode + inline gh review actions, shared persist:github login (sign-in + Chrome cookie import), and a horizontal full-height column band. Repo/author config is read from a gitignored cockpit.config.json (see the committed cockpit.config.example.json) — no private config in the tree. --- pr-cockpit/.gitignore | 4 + pr-cockpit/README.md | 120 ++++ pr-cockpit/chrome-cookies.js | 233 +++++++ pr-cockpit/cockpit.config.example.json | 8 + pr-cockpit/main.js | 343 ++++++++++ pr-cockpit/package-lock.json | 871 +++++++++++++++++++++++++ pr-cockpit/package.json | 13 + pr-cockpit/preload.js | 23 + pr-cockpit/renderer/index.html | 110 ++++ pr-cockpit/renderer/renderer.js | 617 ++++++++++++++++++ pr-cockpit/renderer/style.css | 633 ++++++++++++++++++ 11 files changed, 2975 insertions(+) create mode 100644 pr-cockpit/.gitignore create mode 100644 pr-cockpit/README.md create mode 100644 pr-cockpit/chrome-cookies.js create mode 100644 pr-cockpit/cockpit.config.example.json create mode 100644 pr-cockpit/main.js create mode 100644 pr-cockpit/package-lock.json create mode 100644 pr-cockpit/package.json create mode 100644 pr-cockpit/preload.js create mode 100644 pr-cockpit/renderer/index.html create mode 100644 pr-cockpit/renderer/renderer.js create mode 100644 pr-cockpit/renderer/style.css diff --git a/pr-cockpit/.gitignore b/pr-cockpit/.gitignore new file mode 100644 index 000000000..993146c6a --- /dev/null +++ b/pr-cockpit/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.DS_Store +*.log +cockpit.config.json diff --git a/pr-cockpit/README.md b/pr-cockpit/README.md new file mode 100644 index 000000000..907c937ad --- /dev/null +++ b/pr-cockpit/README.md @@ -0,0 +1,120 @@ +# PR Review Cockpit + +A small standalone Electron desktop app that hosts many GitHub PRs as a grid of +**live webviews**, lets you **focus** one, and **drive** it. + +It replaces a flaky window-tiling script (`open-prs`) that hit Chrome's +~500px minimum-window-width limit (tiles overlapped) and couldn't use iframes +(GitHub sends `X-Frame-Options: DENY`). Electron `` tags are real +browser contexts, so they load github.com fine and we size them freely with no +minimum — panes can go far below Chrome's window minimum with zero overlap. + +## Run + +```bash +cd /Users/jas/pr-cockpit +npm install +npm start +``` + +Requires the [`gh` CLI](https://cli.github.com/) authenticated on this machine +(`gh auth login`). The first time a PR webview loads you may need to sign in to +github.com inside the app; the login persists (shared `persist:github` +session partition) across tiles and restarts. + +## Configure + +Copy the example config and edit it (it is gitignored, so your repo/author +never gets committed): + +```bash +cp cockpit.config.example.json cockpit.config.json +``` + +```json +{ + "repo": "owner/repo", + "author": "your-login", + "me": "your-login", + "state": "open", + "limit": 100, + "onlyToday": false +} +``` + +`author: ""` fetches **all** open PRs. Environment variables override the file +(`COCKPIT_REPO`, `COCKPIT_AUTHOR`, `COCKPIT_ME`, `COCKPIT_STATE`, +`COCKPIT_LIMIT`, `COCKPIT_ONLY_TODAY`). + +On launch (and on **Refresh**) the main process shells out to: + +``` +gh pr list --repo --author --state \ + --json number,title,url,reviewDecision,isDraft,statusCheckRollup --limit +``` + +Each PR gets a derived CI status from `statusCheckRollup`: + +- **red** — any check FAILURE / timed-out / cancelled +- **yellow** — any check queued / in-progress / pending +- **green** — checks present and all passing +- **neutral** — no checks + +## Features + +### Grid mode + +- CSS grid of tiles, one `` per PR. +- Slim header per tile: `# · `, colored by CI status + (green/yellow/red/neutral), plus `draft` / `✓ approved` badges. +- **Cols** control (2–6) — resize panes freely, no overlap. +- **Sort**: Newest (desc PR number) / CI status / Draft-last. + +### Focus mode + +- Click a tile header (or its `#num` placeholder) to focus. +- Focused PR expands to the large right pane; other PRs collapse to a thin + scrollable left rail of header-only **mini-strips**. +- Focused tile gets a blue highlighted border. +- **Esc** or the **⤺ Grid** button exits. Click any mini-strip to switch focus. + +### Drive the focused PR + +Focus-mode toolbar: + +- **Open in browser** — `shell.openExternal(pr.url)` in your real browser. +- **Reload** — `webview.reload()`. +- **Run JS** — text field that runs arbitrary JS in the focused PR page via + `webview.executeJavaScript(...)` and shows the result. This is the seam for + future CDP/automation. + +## Performance / lazy-loading choice + +10+ live Chromium webviews are heavy, so: + +- Every PR gets a tile, but each tile's `<webview>` `src` is **lazy** — attached + only when the tile first becomes live. Unloaded tiles show a clickable + `#num` placeholder. +- In **grid mode** the first `EAGER_LOAD_COUNT` (default **6**) tiles auto-load + for instant usefulness; the rest load on click. +- In **focus mode** only the focused webview is live; other PRs are + header-only mini-strips (their `<webview>` body is CSS-hidden but the element + and its loaded state persist, so re-focusing an already-loaded PR is instant). + +Tune `EAGER_LOAD_COUNT` at the top of `renderer/renderer.js`. + +## Files + +- `package.json` — deps + `start` script. +- `main.js` — main process: window, `gh` IPC, external-open, CI-status derivation. +- `preload.js` — context-isolated bridge (`window.cockpit`). +- `renderer/index.html` / `style.css` / `renderer.js` — the UI. + +## TODO / v2 ideas + +- Deeper CDP automation off the Run JS seam (scripted review flows). +- Review actions via the GitHub API/`gh` (approve / request-changes / comment) + from the focus toolbar. +- Auto-refresh on an interval; desktop notifications on CI status change. +- Per-PR screenshot thumbnails in the rail instead of header-only strips. +- Persist column/sort/focus preferences between runs. diff --git a/pr-cockpit/chrome-cookies.js b/pr-cockpit/chrome-cookies.js new file mode 100644 index 000000000..46101d876 --- /dev/null +++ b/pr-cockpit/chrome-cookies.js @@ -0,0 +1,233 @@ +// chrome-cookies.js — read the logged-in GitHub session out of the user's real +// Chrome and hand it to Electron as cookie-set params. +// +// Why this exists: the cockpit's <webview> tiles use a `persist:github` session +// partition that starts empty, so private-repo PRs 404 / demand sign-in. Rather +// than sign in again inside the app, we "pass the state in from Chrome" — copy +// Chrome's github.com cookies into our session so tiles load already authed. +// +// How it works (macOS only): +// - Chrome stores cookies in a SQLite DB per profile; values are encrypted +// with AES-128-CBC. The key is PBKDF2(HMAC-SHA1) over the "Chrome Safe +// Storage" password held in the login keychain. +// - Reading that keychain item triggers the standard macOS keychain consent +// dialog (Allow / Always Allow) — that prompt IS the authorization. We never +// store or transmit anything; cookies go straight into the local Electron +// session and nowhere else. +// +// Read-only: we copy the DB to a temp file and query it; Chrome is untouched. + +"use strict"; + +const { execFileSync } = require("node:child_process"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const CHROME_DIR = path.join( + os.homedir(), + "Library", + "Application Support", + "Google", + "Chrome" +); + +// AES params fixed by Chrome on macOS. +const SALT = "saltysalt"; +const ITERATIONS = 1003; +const KEYLEN = 16; +const IV = Buffer.alloc(16, 0x20); // 16 spaces + +// Pull the "Chrome Safe Storage" password from the login keychain and derive +// the AES key. Throws (with a friendly message) if the user denies the prompt. +function deriveKey() { + let pw; + try { + pw = execFileSync( + "security", + ["find-generic-password", "-w", "-s", "Chrome Safe Storage"], + { encoding: "utf8" } + ).trim(); + } catch (err) { + throw new Error( + "Could not read the Chrome Safe Storage key from the keychain " + + "(denied or not found). Allow the keychain prompt and try again." + ); + } + if (!pw) throw new Error("Empty Chrome Safe Storage key."); + return crypto.pbkdf2Sync(pw, SALT, ITERATIONS, KEYLEN, "sha1"); +} + +const isPrintable = (buf) => buf.every((c) => c >= 0x20 && c < 0x7f); + +// Decrypt one Chrome-encrypted cookie value (hex of the `encrypted_value` blob). +function decryptValue(hex, key) { + if (!hex) return ""; + const enc = Buffer.from(hex, "hex"); + if (enc.length === 0) return ""; + // Only the v10 scheme is used on macOS; anything else we can't read. + if (enc.subarray(0, 3).toString("latin1") !== "v10") return ""; + const decipher = crypto.createDecipheriv("aes-128-cbc", key, IV); + let out = Buffer.concat([decipher.update(enc.subarray(3)), decipher.final()]); + // Chrome ~v130+ on macOS prepends a 32-byte SHA-256(domain) to the plaintext. + // Detect it: real cookie values are printable ASCII; the hash is not. + if ( + out.length > 32 && + !isPrintable(out.subarray(0, 32)) && + isPrintable(out.subarray(32, Math.min(out.length, 64))) + ) { + out = out.subarray(32); + } + return out.toString("utf8"); +} + +// List candidate profile dirs (Default, Profile 1, Profile 2, …). +function listProfiles() { + let entries; + try { + entries = fs.readdirSync(CHROME_DIR, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter( + (e) => + e.isDirectory() && + (e.name === "Default" || /^Profile \d+$/.test(e.name)) + ) + .map((e) => e.name); +} + +// Copy a profile's cookie DB (+ wal/shm) to a temp file and query github rows. +function readRawCookies(profile) { + const src = path.join(CHROME_DIR, profile, "Cookies"); + if (!fs.existsSync(src)) return []; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cockpit-cookies-")); + const tmp = path.join(tmpDir, "Cookies"); + try { + for (const suffix of ["", "-wal", "-shm"]) { + const s = src + suffix; + if (fs.existsSync(s)) fs.copyFileSync(s, tmp + suffix); + } + const sql = + "SELECT host_key, name, path, is_secure, is_httponly, samesite, " + + "expires_utc, value, hex(encrypted_value) AS enc " + + "FROM cookies WHERE host_key LIKE '%github.com';"; + const out = execFileSync("sqlite3", ["-json", "-readonly", tmp, sql], { + encoding: "utf8", + maxBuffer: 50 * 1024 * 1024, + }); + return out.trim() ? JSON.parse(out) : []; + } catch { + return []; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +// Chrome samesite int → Electron sameSite string. +function sameSiteOf(n) { + switch (n) { + case 0: + return "no_restriction"; + case 1: + return "lax"; + case 2: + return "strict"; + default: + return "unspecified"; + } +} + +// Chrome expires_utc (µs since 1601-01-01) → unix seconds, or undefined (session). +function expirationOf(expiresUtc) { + if (!expiresUtc || expiresUtc === 0) return undefined; + const unix = Number(expiresUtc) / 1e6 - 11644473600; + return unix > 0 ? unix : undefined; +} + +// Turn a decrypted raw row into Electron cookies.set() params. +function toElectronCookie(row, value) { + const host = row.host_key; // may start with "." + const hostNoDot = host.replace(/^\./, ""); + const cpath = row.path || "/"; + const url = `https://${hostNoDot}${cpath}`; + const isHostPrefixed = row.name.startsWith("__Host-"); + let sameSite = sameSiteOf(row.samesite); + let secure = row.is_secure === 1; + // Electron rejects sameSite=no_restriction on a non-secure cookie. + if (sameSite === "no_restriction") secure = true; + + const cookie = { + url, + name: row.name, + value, + path: cpath, + secure, + httpOnly: row.is_httponly === 1, + sameSite, + }; + // __Host- cookies must have no domain; others carry the stored host_key so + // domain cookies (leading-dot) keep working. + if (!isHostPrefixed) cookie.domain = host; + const exp = expirationOf(row.expires_utc); + if (exp !== undefined) cookie.expirationDate = exp; + return cookie; +} + +// Public: find the logged-in Chrome profile and return its github cookies as +// Electron cookies.set() params. Prefers a profile that actually has a +// `user_session` (i.e. is signed in); falls back to the one with the most +// github cookies. +function getGithubCookies() { + const key = deriveKey(); + const profiles = listProfiles(); + if (profiles.length === 0) { + throw new Error(`No Chrome profiles found under ${CHROME_DIR}.`); + } + + let best = null; // { profile, cookies, loggedIn } + for (const profile of profiles) { + const rows = readRawCookies(profile); + if (rows.length === 0) continue; + const cookies = []; + let loggedIn = false; + for (const row of rows) { + let value = row.value; + if (!value && row.enc) { + try { + value = decryptValue(row.enc, key); + } catch { + value = ""; + } + } + if (!value) continue; + if (row.name === "user_session") loggedIn = true; + try { + cookies.push(toElectronCookie(row, value)); + } catch { + /* skip a cookie we can't shape */ + } + } + if (cookies.length === 0) continue; + const candidate = { profile, cookies, loggedIn }; + if ( + !best || + (loggedIn && !best.loggedIn) || + (loggedIn === best.loggedIn && cookies.length > best.cookies.length) + ) { + best = candidate; + } + } + + if (!best) { + throw new Error( + "No github.com cookies found in any Chrome profile. Sign in to " + + "github.com in Chrome first." + ); + } + return best; +} + +module.exports = { getGithubCookies }; diff --git a/pr-cockpit/cockpit.config.example.json b/pr-cockpit/cockpit.config.example.json new file mode 100644 index 000000000..d4868b6ad --- /dev/null +++ b/pr-cockpit/cockpit.config.example.json @@ -0,0 +1,8 @@ +{ + "repo": "owner/repo", + "author": "", + "me": "", + "state": "open", + "limit": 100, + "onlyToday": false +} diff --git a/pr-cockpit/main.js b/pr-cockpit/main.js new file mode 100644 index 000000000..9f10e16c1 --- /dev/null +++ b/pr-cockpit/main.js @@ -0,0 +1,343 @@ +// PR Review Cockpit — main process. +// Responsibilities: +// - create the BrowserWindow that hosts the renderer (which owns the <webview> grid) +// - shell out to `gh` to list PRs and hand them to the renderer over IPC +// - open external URLs in the real browser on request +// +// This app is a personal dev tool. It does no writes to GitHub — it only +// reads the PR list and renders each PR's page inside a real browser context. + +"use strict"; + +const { app, BrowserWindow, ipcMain, shell } = require("electron"); +const { execFile } = require("node:child_process"); +const path = require("node:path"); +const fs = require("node:fs"); + +// --------------------------------------------------------------------------- +// CONFIG — which repo / author the cockpit points at. +// +// Real values live in a gitignored `cockpit.config.json` next to this file +// (copy `cockpit.config.example.json`). Environment variables override the +// file. The defaults below are neutral placeholders so nothing private is ever +// committed. Precedence: env vars > cockpit.config.json > defaults. +// --------------------------------------------------------------------------- +function loadConfig() { + const defaults = { + repo: "owner/repo", // e.g. "cli/cli" + author: "", // "" = fetch ALL open PRs (no --author filter) + me: "", // your login — powers the review-priority "mine" score + state: "open", + limit: 100, + onlyToday: false, // true = only PRs created today (local date) + }; + + let fileCfg = {}; + try { + const p = path.join(__dirname, "cockpit.config.json"); + if (fs.existsSync(p)) fileCfg = JSON.parse(fs.readFileSync(p, "utf8")); + } catch (err) { + console.warn("cockpit.config.json is unreadable:", err.message); + } + + const env = {}; + const e = process.env; + if (e.COCKPIT_REPO) env.repo = e.COCKPIT_REPO; + if (e.COCKPIT_AUTHOR !== undefined) env.author = e.COCKPIT_AUTHOR; + if (e.COCKPIT_ME) env.me = e.COCKPIT_ME; + if (e.COCKPIT_STATE) env.state = e.COCKPIT_STATE; + if (e.COCKPIT_LIMIT) env.limit = Number(e.COCKPIT_LIMIT) || defaults.limit; + if (e.COCKPIT_ONLY_TODAY) env.onlyToday = e.COCKPIT_ONLY_TODAY === "true"; + + return { ...defaults, ...fileCfg, ...env }; +} + +const CONFIG = loadConfig(); +// --------------------------------------------------------------------------- + +let mainWindow = null; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1600, + height: 1000, + fullscreen: true, + backgroundColor: "#0d1117", + title: "PR Review Cockpit", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + // Enable <webview> tags — these are real browser contexts (unlike + // iframes) so github.com's X-Frame-Options: DENY does not block them. + webviewTag: true, + }, + }); + + mainWindow.loadFile(path.join(__dirname, "renderer", "index.html")); + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +// Run `gh pr list ...` and return parsed JSON (array of PRs), or throw a +// friendly Error the renderer can display. +function listPullRequests() { + const args = [ + "pr", + "list", + "--repo", + CONFIG.repo, + "--state", + CONFIG.state, + "--json", + "number,title,url,statusCheckRollup,isDraft,reviewDecision,mergeable,createdAt,updatedAt,author,reviewRequests", + "--limit", + String(CONFIG.limit), + ]; + + // Only constrain by author when one is configured; empty means "all PRs". + if (CONFIG.author) { + args.push("--author", CONFIG.author); + } + + return new Promise((resolve, reject) => { + execFile( + "gh", + args, + { maxBuffer: 20 * 1024 * 1024, timeout: 30000 }, + (err, stdout, stderr) => { + if (err) { + if (err.code === "ENOENT") { + reject( + new Error( + "`gh` CLI not found on PATH. Install GitHub CLI and run `gh auth login`." + ) + ); + return; + } + const detail = (stderr || err.message || "").trim(); + reject( + new Error( + `gh failed (exit ${err.code ?? "?"}): ${detail || "unknown error"}` + ) + ); + return; + } + try { + const raw = JSON.parse(stdout || "[]"); + resolve(raw.map(deriveStatus)); + } catch (parseErr) { + reject( + new Error(`Could not parse gh JSON output: ${parseErr.message}`) + ); + } + } + ); + }); +} + +// Collapse statusCheckRollup + reviewDecision into one of: +// "fail" (red) | "run" (yellow, in-progress) | "ok" (green) | "none" +function deriveStatus(pr) { + const rollup = Array.isArray(pr.statusCheckRollup) + ? pr.statusCheckRollup + : []; + + let anyFailure = false; + let anyPending = false; + let anyCheck = false; + + for (const check of rollup) { + anyCheck = true; + // Two shapes appear: CheckRun (status/conclusion) and StatusContext (state). + const conclusion = (check.conclusion || "").toUpperCase(); + const status = (check.status || "").toUpperCase(); + const state = (check.state || "").toUpperCase(); + + if ( + conclusion === "FAILURE" || + conclusion === "TIMED_OUT" || + conclusion === "CANCELLED" || + conclusion === "ACTION_REQUIRED" || + state === "FAILURE" || + state === "ERROR" + ) { + anyFailure = true; + } else if ( + status === "QUEUED" || + status === "IN_PROGRESS" || + status === "PENDING" || + state === "PENDING" || + (conclusion === "" && status !== "COMPLETED" && state === "") + ) { + anyPending = true; + } + } + + const reviewDecision = pr.reviewDecision || null; + const approved = (reviewDecision || "").toUpperCase() === "APPROVED"; + + let ciStatus; + if (anyFailure) ciStatus = "fail"; + else if (anyPending) ciStatus = "run"; + else if (anyCheck) ciStatus = "ok"; // checks passed (approval is tracked separately) + else ciStatus = "none"; + + const authorLogin = (pr.author && pr.author.login) || ""; + const me = CONFIG.me || ""; + const reviewRequests = Array.isArray(pr.reviewRequests) + ? pr.reviewRequests + : []; + + return { + number: pr.number, + title: pr.title, + url: pr.url, + ciStatus, + approved, + isDraft: !!pr.isDraft, + reviewDecision, + mergeable: pr.mergeable || "UNKNOWN", + createdAt: pr.createdAt || "", + updatedAt: pr.updatedAt || "", + authorLogin, + isMine: !!me && authorLogin === me, + reviewRequestedFromMe: + !!me && reviewRequests.some((r) => r && r.login === me), + }; +} + +// --- IPC --------------------------------------------------------------------- + +ipcMain.handle("prs:list", async () => { + try { + let prs = await listPullRequests(); + if (CONFIG.onlyToday) { + const start = new Date(); + start.setHours(0, 0, 0, 0); // local start-of-today + prs = prs.filter((p) => p.createdAt && new Date(p.createdAt) >= start); + } + return { ok: true, prs, config: CONFIG }; + } catch (err) { + return { ok: false, error: err.message, config: CONFIG }; + } +}); + +// Perform a real GitHub review action via the `gh` CLI (authed on this host). +// These go through `gh`, NOT the webviews, so they work even when the tiles' +// github.com session isn't signed in. +// +// SAFETY: the review/comment body is passed as a discrete argv element to +// execFile — it is NEVER interpolated into a shell string, so arbitrary text +// (quotes, backticks, $()) cannot break out into command execution. +function runPrAction({ kind, number, body }) { + const num = String(number).trim(); + if (!/^\d+$/.test(num)) { + return Promise.resolve({ ok: false, error: `Bad PR number: ${number}` }); + } + const text = typeof body === "string" ? body.trim() : ""; + + let args; + switch (kind) { + case "approve": + // Optional body; only include --body when non-empty. + args = ["pr", "review", num, "--repo", CONFIG.repo, "--approve"]; + if (text) args.push("--body", text); + break; + case "request-changes": + if (!text) { + return Promise.resolve({ + ok: false, + error: "Request-changes needs a non-empty comment.", + }); + } + args = [ + "pr", + "review", + num, + "--repo", + CONFIG.repo, + "--request-changes", + "--body", + text, + ]; + break; + case "comment": + if (!text) { + return Promise.resolve({ + ok: false, + error: "Comment needs a non-empty body.", + }); + } + args = ["pr", "comment", num, "--repo", CONFIG.repo, "--body", text]; + break; + case "dryrun": + // Diagnostic only — echoes the argv that WOULD run, fires nothing. + return Promise.resolve({ + ok: true, + dryrun: true, + output: `gh ${["pr", "…", num, "--repo", CONFIG.repo].join(" ")}`, + }); + default: + return Promise.resolve({ ok: false, error: `Unknown action: ${kind}` }); + } + + return new Promise((resolve) => { + execFile( + "gh", + args, + { maxBuffer: 4 * 1024 * 1024, timeout: 30000 }, + (err, stdout, stderr) => { + if (err) { + if (err.code === "ENOENT") { + resolve({ + ok: false, + error: "`gh` CLI not found on PATH.", + }); + return; + } + const detail = (stderr || err.message || "").trim(); + resolve({ + ok: false, + error: `gh failed (exit ${err.code ?? "?"}): ${detail || "unknown error"}`, + }); + return; + } + resolve({ + ok: true, + output: (stdout || stderr || "done").trim(), + }); + } + ); + }); +} + +ipcMain.handle("pr-action", async (_event, payload) => { + try { + return await runPrAction(payload || {}); + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +ipcMain.handle("shell:openExternal", async (_event, url) => { + if (typeof url === "string" && /^https?:\/\//.test(url)) { + await shell.openExternal(url); + return { ok: true }; + } + return { ok: false, error: "Refused to open non-http(s) url." }; +}); + +// --- lifecycle --------------------------------------------------------------- + +app.whenReady().then(() => { + createWindow(); + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); diff --git a/pr-cockpit/package-lock.json b/pr-cockpit/package-lock.json new file mode 100644 index 000000000..5cef5120b --- /dev/null +++ b/pr-cockpit/package-lock.json @@ -0,0 +1,871 @@ +{ + "name": "pr-cockpit", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pr-cockpit", + "version": "1.0.0", + "devDependencies": { + "electron": "^33.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "33.4.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", + "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/pr-cockpit/package.json b/pr-cockpit/package.json new file mode 100644 index 000000000..3e9a7027c --- /dev/null +++ b/pr-cockpit/package.json @@ -0,0 +1,13 @@ +{ + "name": "pr-cockpit", + "version": "1.0.0", + "description": "PR Review Cockpit — host many GitHub PRs as a grid of live Electron webviews, focus one and drive it.", + "main": "main.js", + "private": true, + "scripts": { + "start": "electron ." + }, + "devDependencies": { + "electron": "^33.0.0" + } +} diff --git a/pr-cockpit/preload.js b/pr-cockpit/preload.js new file mode 100644 index 000000000..85509ce43 --- /dev/null +++ b/pr-cockpit/preload.js @@ -0,0 +1,23 @@ +// Preload bridge — exposes a tiny, explicit API to the renderer. +// contextIsolation is on, so the renderer can only touch what we expose here. + +"use strict"; + +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("cockpit", { + // Fetch the PR list. Resolves to { ok, prs?, error?, config }. + listPRs: () => ipcRenderer.invoke("prs:list"), + // Open a url in the user's real default browser. + openExternal: (url) => ipcRenderer.invoke("shell:openExternal", url), + // Perform a real GitHub review action via the `gh` CLI on this host (approve / + // request-changes / comment). Goes through gh, not the webviews, so it works + // even when the tiles aren't signed in. Resolves to { ok, output? } or + // { ok:false, error }. kind: "approve" | "request-changes" | "comment". + prAction: ({ kind, number, body }) => + ipcRenderer.invoke("pr-action", { kind, number, body }), + // Import the existing github.com login from Chrome into the shared + // persist:github session. Resolves to { ok, profile, set, total } or + // { ok:false, error }. No-op if the main-process glue isn't wired in. + syncGithub: () => ipcRenderer.invoke("github:syncFromChrome"), +}); diff --git a/pr-cockpit/renderer/index.html b/pr-cockpit/renderer/index.html new file mode 100644 index 000000000..fc4718336 --- /dev/null +++ b/pr-cockpit/renderer/index.html @@ -0,0 +1,110 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self';" + /> + <title>PR Review Cockpit + + + +
+
PR Review Cockpit
+ +
+ + + + + + + + + + + +
+ +
+
+ + + + +
+ +
+ +
+ + + + diff --git a/pr-cockpit/renderer/renderer.js b/pr-cockpit/renderer/renderer.js new file mode 100644 index 000000000..6c6172f5b --- /dev/null +++ b/pr-cockpit/renderer/renderer.js @@ -0,0 +1,617 @@ +// PR Review Cockpit — renderer. +// +// Owns the grid, focus mode, sorting, and the focused-PR toolbar. +// +// PERF DESIGN (documented for future me): +// - Every PR gets a tile, but a tile's src is LAZY: we only attach +// the real github.com src the first time a tile becomes visible/live. Until +// then the tile shows a clickable placeholder. This keeps startup cheap even +// with 30+ PRs. +// - In GRID mode we load webviews eagerly-on-first-render but capped: the first +// N tiles auto-load, the rest wait for a click (see EAGER_LOAD_COUNT). This +// is a balance — enough live previews to be useful, not 50 Chromium tabs. +// - In FOCUS mode only the focused webview is live. The other PRs collapse to +// header-only "mini-strips" in the left rail (their body is hidden +// via CSS but the element — and thus its loaded state — is preserved, so +// switching focus back is instant if it was loaded before). + +"use strict"; + +const EAGER_LOAD_COUNT = 6; // how many grid tiles auto-load their webview + +const stage = document.getElementById("stage"); +const meta = document.getElementById("meta"); +const statusEl = document.getElementById("status"); +const backBtn = document.getElementById("backBtn"); +const focusToolbar = document.getElementById("focusToolbar"); +const focusLabel = document.getElementById("focusLabel"); +const colsSelect = document.getElementById("colsSelect"); +const sortSelect = document.getElementById("sortSelect"); +const jsInput = document.getElementById("jsInput"); +const jsResult = document.getElementById("jsResult"); +const reviewBody = document.getElementById("reviewBody"); +const approveBtn = document.getElementById("approveBtn"); +const reqChangesBtn = document.getElementById("reqChangesBtn"); +const commentBtn = document.getElementById("commentBtn"); + +const state = { + prs: [], + loaded: new Set(), // PR numbers whose webview src is attached + tiles: new Map(), // PR number -> { el, header, webview, placeholder } + focused: null, // focused PR number or null + cols: 3, + sort: "priority", +}; + +// --- helpers ----------------------------------------------------------------- + +// A short "what are we looking at" label for the top-right meta area. +function scopeLabel(config) { + if (!config) return ""; + const who = config.author + ? `${config.repo} · @${config.author}` + : `${config.repo} · all open`; + return config.onlyToday ? `${who} · started today` : who; +} + +function setStatus(msg, isError = false) { + statusEl.textContent = msg || ""; + statusEl.classList.toggle("error", !!isError); + if (msg && !isError) { + clearTimeout(setStatus._t); + setStatus._t = setTimeout(() => { + if (statusEl.textContent === msg) statusEl.textContent = ""; + }, 3500); + } +} + +// --- settledness ------------------------------------------------------------- +// A short human label + css-class for "where is this PR in its lifecycle?" +// Order matters: the first matching rule wins. +function settledness(pr) { + const ci = pr.ciStatus; + const rd = pr.reviewDecision; + const mg = pr.mergeable; + + if (pr.isDraft) return { label: "draft", cls: "draft" }; + if (mg === "CONFLICTING") return { label: "conflicting", cls: "conflicting" }; + if (rd === "CHANGES_REQUESTED") + return { label: "changes req", cls: "changes-req" }; + if (ci === "fail") return { label: "ci failing", cls: "ci-failing" }; + if (rd === "APPROVED" && mg === "MERGEABLE" && ci === "ok") + return { label: "ready to merge", cls: "ready-merge" }; + if ( + mg === "MERGEABLE" && + (ci === "ok" || ci === "run") && + rd === "REVIEW_REQUIRED" + ) + return { label: "ready for review", cls: "ready-review" }; + if (mg === "UNKNOWN") return { label: "stale?", cls: "stale" }; + return { label: "open", cls: "open" }; +} + +// --- review priority --------------------------------------------------------- +// Higher score = should be reviewed sooner. Tiebreak by updatedAt desc. +function reviewPriority(pr) { + const teammate = !pr.isMine; + const ci = pr.ciStatus; + const rd = pr.reviewDecision; + const mg = pr.mergeable; + const mergeableCi = ci === "ok" || ci === "run"; + + if (teammate && !pr.isDraft && mg === "MERGEABLE" && mergeableCi && + rd === "REVIEW_REQUIRED") + return 100; // their PR is waiting on review + if (teammate && !pr.isDraft && mg === "MERGEABLE" && ci === "fail") + return 90; // teammate, mergeable, CI red (e.g. security) + if (pr.isMine && !pr.isDraft && mg === "MERGEABLE" && ci === "ok") + return 80; // my ready-to-land + if (pr.isMine && !pr.isDraft && ci === "fail") + return 70; // my CI red — fix + if (!pr.isDraft && (mg === "CONFLICTING" || rd === "CHANGES_REQUESTED")) + return 60; // needs work + if (pr.isDraft) return 30; + if (mg === "UNKNOWN") return 10; // stale + return 40; +} + +function updatedDesc(a, b) { + // newer updatedAt first; missing timestamps sort last + return (b.updatedAt || "").localeCompare(a.updatedAt || ""); +} + +function sortPRs(prs, mode) { + const arr = [...prs]; + if (mode === "priority") { + arr.sort( + (a, b) => + reviewPriority(b) - reviewPriority(a) || updatedDesc(a, b) + ); + } else if (mode === "newest") { + arr.sort((a, b) => b.number - a.number); + } else if (mode === "status") { + const rank = { fail: 0, run: 1, none: 2, ok: 3 }; + arr.sort( + (a, b) => + (rank[a.ciStatus] ?? 9) - (rank[b.ciStatus] ?? 9) || + b.number - a.number + ); + } else if (mode === "draft-last") { + arr.sort( + (a, b) => + Number(a.isDraft) - Number(b.isDraft) || b.number - a.number + ); + } + return arr; +} + +// --- status hue injection into the PR webview -------------------------------- +// We inject CSS into each PR's so the live GitHub page itself takes on +// its CI-status hue — the WHOLE pr reads as green/yellow/red, not just the tile +// chrome. mix-blend-mode:color shifts hue while preserving luminance, so text +// stays readable. Bump TINT_ALPHA for a stronger shift. + +const TINT_ALPHA = 0.16; // 0..1 — how hard the PR page takes its status hue + +const STATUS_HUE = { + ok: { c: "#3fb950", glow: "rgba(63,185,80,0.14)" }, + run: { c: "#d29922", glow: "rgba(210,153,34,0.14)" }, + fail: { c: "#f85149", glow: "rgba(248,81,73,0.16)" }, + none: { c: "#6e7681", glow: "rgba(110,118,129,0.10)" }, +}; + +function statusTintCSS(ciStatus) { + const s = STATUS_HUE[ciStatus] || STATUS_HUE.none; + // neutral gets only a frame — a gray "color" blend would desaturate the page. + const hueLayer = + ciStatus === "none" + ? "" + : `html::before{content:"";position:fixed;inset:0;pointer-events:none;` + + `z-index:2147483646;background:${s.c};mix-blend-mode:color;` + + `opacity:${TINT_ALPHA};}`; + const frame = + `html::after{content:"";position:fixed;inset:0;pointer-events:none;` + + `z-index:2147483647;box-shadow:inset 0 0 0 2px ${s.c},inset 0 0 20px ${s.glow};}`; + return hueLayer + frame; +} + +// Attach a dom-ready handler that (re)injects the tint on every load/navigation. +function injectStatusTint(webview, ciStatus) { + webview.addEventListener("dom-ready", async () => { + try { + if (webview._tintKey) { + await webview.removeInsertedCSS(webview._tintKey).catch(() => {}); + } + webview._tintKey = await webview.insertCSS(statusTintCSS(ciStatus)); + } catch { + /* webview not ready / navigated away — ignore */ + } + }); +} + +// --- tile construction ------------------------------------------------------- + +function makeTile(pr) { + const el = document.createElement("div"); + el.className = "tile"; + el.classList.add(`st-${pr.ciStatus}`); // color-code the whole tile by CI status + el.dataset.number = String(pr.number); + + const header = document.createElement("div"); + header.className = `tile-header st-${pr.ciStatus}`; + + const num = document.createElement("span"); + num.className = "num"; + num.textContent = `#${pr.number}`; + + const title = document.createElement("span"); + title.className = "title"; + title.textContent = pr.title; + title.title = pr.title; + + const badges = document.createElement("span"); + badges.className = "badges"; + + // settledness pill — the headline "where is this PR?" marker. + const settle = settledness(pr); + const pill = document.createElement("span"); + pill.className = `pill settle-${settle.cls}`; + pill.textContent = settle.label; + badges.appendChild(pill); + + // author login (+ MINE marker for my own PRs) + const author = document.createElement("span"); + author.className = "badge author" + (pr.isMine ? " mine" : ""); + author.textContent = pr.isMine + ? `MINE · ${pr.authorLogin}` + : pr.authorLogin; + author.title = pr.authorLogin; + badges.appendChild(author); + + if (pr.approved) { + const a = document.createElement("span"); + a.className = "badge"; + a.textContent = "✓ approved"; + badges.appendChild(a); + } + + header.append(num, title, badges); + header.addEventListener("click", () => enterFocus(pr.number)); + + const body = document.createElement("div"); + body.className = "tile-body"; + + // webview element (created up front, src attached lazily) + const webview = document.createElement("webview"); + webview.setAttribute("partition", "persist:github"); // shared login session + webview.setAttribute("allowpopups", "true"); + body.appendChild(webview); + injectStatusTint(webview, pr.ciStatus); // hue-shift the live PR page by status + + const placeholder = document.createElement("div"); + placeholder.className = "placeholder"; + const big = document.createElement("div"); + big.className = "big"; + big.textContent = `#${pr.number}`; + const hint = document.createElement("div"); + hint.textContent = "click to load PR"; + placeholder.append(big, hint); + placeholder.addEventListener("click", (e) => { + e.stopPropagation(); + loadWebview(pr.number); + }); + body.appendChild(placeholder); + + el.append(header, body); + + const rec = { el, header, webview, placeholder }; + state.tiles.set(pr.number, rec); + return rec; +} + +function loadWebview(number) { + const rec = state.tiles.get(number); + const pr = state.prs.find((p) => p.number === number); + if (!rec || !pr) return; + if (!state.loaded.has(number)) { + rec.webview.setAttribute("src", pr.url); + state.loaded.add(number); + } + rec.placeholder.classList.add("hidden"); +} + +// --- rendering --------------------------------------------------------------- + +function render() { + stage.style.setProperty("--cols", String(state.cols)); + + if (state.focused == null) { + renderGrid(); + } else { + renderFocus(); + } +} + +function renderGrid() { + stage.className = "grid"; + backBtn.classList.add("hidden"); + focusToolbar.classList.add("hidden"); + stage.replaceChildren(); + + const sorted = sortPRs(state.prs, state.sort); + sorted.forEach((pr, i) => { + const rec = state.tiles.get(pr.number) || makeTile(pr); + rec.el.classList.remove("mini", "focused"); + // restore placeholder visibility for tiles not yet loaded + if (!state.loaded.has(pr.number)) { + rec.placeholder.classList.remove("hidden"); + } + stage.appendChild(rec.el); + // eager-load the first few for instant usefulness + if (i < EAGER_LOAD_COUNT) loadWebview(pr.number); + }); +} + +function renderFocus() { + stage.className = "focus"; + backBtn.classList.remove("hidden"); + focusToolbar.classList.remove("hidden"); + stage.replaceChildren(); + + const rail = document.createElement("div"); + rail.className = "rail"; + const mainPane = document.createElement("div"); + mainPane.className = "main-pane"; + + const sorted = sortPRs(state.prs, state.sort); + for (const pr of sorted) { + const rec = state.tiles.get(pr.number) || makeTile(pr); + if (pr.number === state.focused) { + rec.el.classList.add("focused"); + rec.el.classList.remove("mini"); + mainPane.appendChild(rec.el); + loadWebview(pr.number); + } else { + rec.el.classList.add("mini"); + rec.el.classList.remove("focused"); + rail.appendChild(rec.el); + } + } + + stage.append(rail, mainPane); + + const focusedPr = state.prs.find((p) => p.number === state.focused); + focusLabel.textContent = focusedPr + ? `#${focusedPr.number} · ${focusedPr.title}` + : ""; +} + +// --- focus mode transitions -------------------------------------------------- + +function enterFocus(number) { + state.focused = number; + render(); +} + +function exitFocus() { + state.focused = null; + render(); +} + +function focusedWebview() { + if (state.focused == null) return null; + const rec = state.tiles.get(state.focused); + return rec ? rec.webview : null; +} + +// --- toolbar wiring ---------------------------------------------------------- + +document.getElementById("openExtBtn").addEventListener("click", async () => { + const pr = state.prs.find((p) => p.number === state.focused); + if (!pr) return; + await window.cockpit.openExternal(pr.url); +}); + +document.getElementById("reloadBtn").addEventListener("click", () => { + const wv = focusedWebview(); + if (wv) { + try { + wv.reload(); + } catch (e) { + setStatus("Reload failed: " + e.message, true); + } + } +}); + +async function runJs() { + const wv = focusedWebview(); + if (!wv) return; + const code = jsInput.value.trim(); + if (!code) return; + jsResult.textContent = "running…"; + try { + const result = await wv.executeJavaScript(code, false); + let out; + try { + out = JSON.stringify(result); + } catch { + out = String(result); + } + jsResult.textContent = "→ " + (out ?? "undefined"); + } catch (e) { + jsResult.textContent = "✗ " + e.message; + } +} + +document.getElementById("runJsBtn").addEventListener("click", runJs); +jsInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") runJs(); +}); + +// --- real GitHub review actions (via gh CLI, not the webview) ---------------- +// These POST publicly to GitHub, so every one asks for an explicit confirm that +// names the action + PR number first. The buttons disable while a request is in +// flight so a double-click can't fire twice. + +const REVIEW_BTNS = [approveBtn, reqChangesBtn, commentBtn]; + +function setActionsBusy(busy) { + for (const b of REVIEW_BTNS) b.disabled = busy; + reviewBody.disabled = busy; +} + +// kind: "approve" | "request-changes" | "comment" +async function fireReviewAction(kind) { + if (state.focused == null) return; + const number = state.focused; + const body = reviewBody.value.trim(); + + // request-changes and comment require a non-empty body. + if ((kind === "request-changes" || kind === "comment") && !body) { + setStatus( + kind === "comment" + ? "Comment needs a body — type it in the review field first." + : "Request-changes needs a body — type it in the review field first.", + true + ); + reviewBody.focus(); + return; + } + + const verb = + kind === "approve" + ? "Approve" + : kind === "request-changes" + ? "Request changes on" + : "Comment on"; + // Real, public GitHub action — require an explicit confirm. + const bodyNote = body ? `\n\nBody:\n${body}` : ""; + if (!window.confirm(`${verb} #${number}?${bodyNote}`)) return; + + setActionsBusy(true); + setStatus(`${verb} #${number}…`); + let res; + try { + res = await window.cockpit.prAction({ kind, number, body }); + } catch (e) { + setStatus(`Action failed: ${e.message}`, true); + setActionsBusy(false); + return; + } + setActionsBusy(false); + + if (!res || !res.ok) { + setStatus(`Action failed: ${(res && res.error) || "unknown"}`, true); + return; + } + setStatus(`${verb} #${number} ✓ ${res.output || "done"}`); + if (kind !== "approve" || body) reviewBody.value = ""; +} + +approveBtn.addEventListener("click", () => fireReviewAction("approve")); +reqChangesBtn.addEventListener("click", () => + fireReviewAction("request-changes") +); +commentBtn.addEventListener("click", () => fireReviewAction("comment")); + +backBtn.addEventListener("click", exitFocus); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && state.focused != null) exitFocus(); +}); + +colsSelect.addEventListener("change", () => { + state.cols = Number(colsSelect.value); + render(); +}); +sortSelect.addEventListener("change", () => { + state.sort = sortSelect.value; + render(); +}); + +document.getElementById("refreshBtn").addEventListener("click", refresh); + +// --- GitHub auth: sign in once (Path B) + import from Chrome (Path A) --------- + +// Reload every tile whose webview is live (after a login changes the session). +function reloadAllWebviews() { + for (const [number, rec] of state.tiles) { + if (state.loaded.has(number) && rec.webview) { + try { + rec.webview.reload(); + } catch { + /* ignore a webview that isn't ready */ + } + } + } +} + +// Path B — open a full-window GitHub login in the SAME persist:github session +// the tiles share, so signing in once here authenticates (and persists for) +// every tile and every future launch. +function openGithubLogin() { + if (document.getElementById("loginOverlay")) return; + + const overlay = document.createElement("div"); + overlay.id = "loginOverlay"; + + const bar = document.createElement("div"); + bar.className = "login-bar"; + + const titleEl = document.createElement("span"); + titleEl.className = "login-title"; + titleEl.textContent = "Sign in to GitHub"; + + const hint = document.createElement("span"); + hint.className = "login-hint"; + hint.textContent = + "This login is shared by every PR tile and is remembered across restarts."; + + const spacer = document.createElement("span"); + spacer.className = "spacer"; + + const done = document.createElement("button"); + done.textContent = "✓ Done — reload PRs"; + done.addEventListener("click", () => { + overlay.remove(); + reloadAllWebviews(); + setStatus("Signed in — reloaded tiles."); + }); + + bar.append(titleEl, hint, spacer, done); + + const wv = document.createElement("webview"); + wv.className = "login-webview"; + wv.setAttribute("partition", "persist:github"); + wv.setAttribute("allowpopups", "true"); + wv.setAttribute("src", "https://github.com/login"); + + overlay.append(bar, wv); + document.body.appendChild(overlay); +} + +document.getElementById("signinBtn").addEventListener("click", openGithubLogin); + +// Path A — pull the existing github.com login out of Chrome via the main +// process (gated at runtime by the macOS keychain prompt). Degrades gracefully +// if the Chrome-import glue isn't wired into main.js yet. +document.getElementById("syncBtn").addEventListener("click", async () => { + if (!window.cockpit || typeof window.cockpit.syncGithub !== "function") { + setStatus("Chrome import isn't available in this build.", true); + return; + } + setStatus("Importing your GitHub login from Chrome…"); + let res; + try { + res = await window.cockpit.syncGithub(); + } catch (e) { + setStatus("Chrome import failed: " + e.message, true); + return; + } + if (!res || !res.ok) { + setStatus("Chrome import failed: " + ((res && res.error) || "unknown"), true); + return; + } + setStatus( + `Imported ${res.set}/${res.total} cookies from Chrome “${res.profile}”. Reloading…` + ); + reloadAllWebviews(); +}); + +// --- data load --------------------------------------------------------------- + +async function refresh() { + setStatus("Loading PRs via gh…"); + const res = await window.cockpit.listPRs(); + if (!res.ok) { + setStatus(res.error, true); + meta.textContent = res.config ? scopeLabel(res.config) : ""; + return; + } + + // Reset tile cache (PRs may have changed). Keep it simple: rebuild. + state.tiles.clear(); + state.loaded.clear(); + state.focused = null; + state.prs = res.prs; + + meta.textContent = `${scopeLabel(res.config)} · ${res.prs.length} PRs`; + setStatus(res.prs.length ? "" : "No open PRs found."); + render(); +} + +// In the horizontal band (grid mode), translate a vertical mouse wheel into +// sideways scroll so mouse users can move through columns (trackpads already +// swipe horizontally; hold Shift or scroll horizontally to bypass). +stage.addEventListener( + "wheel", + (e) => { + if (state.focused != null) return; // focus mode scrolls normally + if (e.shiftKey || e.deltaY === 0) return; + stage.scrollLeft += e.deltaY; + e.preventDefault(); + }, + { passive: false } +); + +// boot +refresh(); diff --git a/pr-cockpit/renderer/style.css b/pr-cockpit/renderer/style.css new file mode 100644 index 000000000..66f3ebeb9 --- /dev/null +++ b/pr-cockpit/renderer/style.css @@ -0,0 +1,633 @@ +/* PR Review Cockpit — theme. + Automatic light/dark via prefers-color-scheme. Every color is a variable so + the two themes share one layout. Status (CI) coloring paints the WHOLE tile, + not just the header. */ + +:root { + color-scheme: light dark; + + /* dark (default) */ + --bg: #0d1117; + --bg-2: #0a0e14; + --panel: #161b22; + --panel-2: #1c232c; + --elev: #21262d; + --elev-hi: #30363d; + --border: #30363d; + --border-soft: #262c34; + --text: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; + --accent-soft: rgba(88, 166, 255, 0.35); + + --green: #3fb950; + --yellow: #d29922; + --red: #f85149; + --neutral: #6e7681; + + /* soft status tints for backgrounds / glows */ + --green-tint: rgba(63, 185, 80, 0.3); + --yellow-tint: rgba(210, 153, 34, 0.3); + --red-tint: rgba(248, 81, 73, 0.32); + --neutral-tint: rgba(110, 118, 129, 0.2); + --green-glow: rgba(63, 185, 80, 0.55); + --yellow-glow: rgba(210, 153, 34, 0.55); + --red-glow: rgba(248, 81, 73, 0.55); + --neutral-glow: rgba(110, 118, 129, 0.32); + + --shadow: 0 6px 20px rgba(0, 0, 0, 0.45); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.35); + --sheen: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.06), + rgba(255, 255, 255, 0) + ); + --tile-body-bg: #ffffff; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f6f8fa; + --bg-2: #eaeef2; + --panel: #ffffff; + --panel-2: #f6f8fa; + --elev: #f3f4f6; + --elev-hi: #e7ebef; + --border: #d0d7de; + --border-soft: #e2e6ea; + --text: #1f2328; + --muted: #636c76; + --accent: #0969da; + --accent-soft: rgba(9, 105, 218, 0.28); + + --green: #1a7f37; + --yellow: #9a6700; + --red: #cf222e; + --neutral: #6e7781; + + --green-tint: rgba(26, 127, 55, 0.1); + --yellow-tint: rgba(154, 103, 0, 0.1); + --red-tint: rgba(207, 34, 46, 0.09); + --neutral-tint: rgba(110, 119, 129, 0.08); + --green-glow: rgba(26, 127, 55, 0.22); + --yellow-glow: rgba(154, 103, 0, 0.22); + --red-glow: rgba(207, 34, 46, 0.22); + --neutral-glow: rgba(110, 119, 129, 0.16); + + --shadow: 0 6px 20px rgba(140, 149, 159, 0.25); + --shadow-sm: 0 1px 4px rgba(140, 149, 159, 0.2); + --sheen: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.7), + rgba(255, 255, 255, 0) + ); + --tile-body-bg: #ffffff; + } +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + background: radial-gradient( + 1200px 600px at 100% -10%, + var(--panel-2), + transparent 60% + ), + var(--bg); + color: var(--text); + font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, + sans-serif; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; +} + +/* --- top bar --- */ +#topbar { + display: flex; + align-items: center; + gap: 14px; + padding: 9px 14px; + background: linear-gradient(180deg, var(--panel), var(--panel-2)); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow-sm); + flex: 0 0 auto; + z-index: 10; +} + +.brand { + font-weight: 800; + letter-spacing: 0.3px; + background: linear-gradient(92deg, var(--accent), var(--text)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.controls { + display: flex; + align-items: center; + gap: 10px; +} + +.ctl { + display: flex; + align-items: center; + gap: 4px; + color: var(--muted); + font-size: 12px; +} + +.meta { + margin-left: auto; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +button, +select, +input[type="text"] { + background: linear-gradient(180deg, var(--elev), var(--panel-2)); + color: var(--text); + border: 1px solid var(--border); + border-radius: 7px; + padding: 5px 10px; + font: inherit; + cursor: pointer; + box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.06); + transition: + transform 0.06s ease, + background 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +input[type="text"] { + cursor: text; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); +} + +button:hover { + background: linear-gradient(180deg, var(--elev-hi), var(--elev)); + border-color: var(--accent-soft); +} + +button:active { + transform: translateY(1px); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +/* accent the two auth buttons a touch */ +#signinBtn { + border-color: var(--accent-soft); +} +#syncBtn { + color: var(--accent); +} + +.hidden { + display: none !important; +} + +/* --- focus toolbar --- */ +.focus-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + background: linear-gradient(180deg, var(--panel-2), var(--bg-2)); + border-bottom: 1px solid var(--accent-soft); + flex: 0 0 auto; +} + +.focus-label { + font-weight: 600; + color: var(--accent); + white-space: nowrap; +} + +#jsInput { + flex: 1 1 auto; + min-width: 120px; +} + +/* --- real GitHub review actions (in the focus toolbar) --- */ +.review-actions { + display: flex; + align-items: center; + gap: 6px; + padding-left: 8px; + margin-left: 4px; + border-left: 1px solid var(--border); +} + +#reviewBody { + width: 200px; + min-width: 120px; +} + +.act-approve { + color: var(--green); + border-color: var(--green); + font-weight: 700; +} +.act-approve:hover { + border-color: var(--green); + box-shadow: 0 0 0 2px var(--green-glow); +} + +.act-reject { + color: var(--red); + border-color: var(--red); + font-weight: 700; +} +.act-reject:hover { + border-color: var(--red); + box-shadow: 0 0 0 2px var(--red-glow); +} + +.act-comment { + color: var(--accent); + border-color: var(--accent-soft); +} + +.review-actions button:disabled, +#reviewBody:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: var(--shadow-sm); +} + +.js-result { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --- stage --- */ +#stage { + flex: 1 1 auto; + overflow: auto; + padding: 10px; +} + +/* Horizontal band of full-height columns — one PR per column, scroll sideways. + `Cols` controls how many columns are visible at once; the rest scroll in. */ +#stage.grid { + display: grid; + grid-auto-flow: column; + grid-template-rows: 1fr; + grid-auto-columns: calc((100% - (var(--cols, 3) - 1) * 10px) / var(--cols, 3)); + gap: 10px; + overflow-x: auto; + overflow-y: hidden; +} + +#stage.focus { + display: grid; + grid-template-columns: 230px 1fr; + gap: 10px; +} + +.rail { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 7px; + min-height: 0; +} + +.main-pane { + min-height: 0; + display: flex; + flex-direction: column; +} + +/* --- tile --- */ +.tile { + --st: var(--neutral); + --st-tint: var(--neutral-tint); + --st-glow: var(--neutral-glow); + display: flex; + flex-direction: column; + background: linear-gradient(180deg, var(--panel), var(--panel-2)); + border: 1px solid var(--border); + border-top: 4px solid var(--st); + border-radius: 10px; + overflow: hidden; + min-height: 260px; + box-shadow: var(--shadow-sm); + transition: + box-shadow 0.18s ease, + transform 0.1s ease, + border-color 0.18s ease; +} + +#stage.grid .tile { + height: 100%; + min-height: 0; +} + +#stage.grid .tile:hover { + transform: translateY(-1px); + box-shadow: + var(--shadow), + 0 0 0 1px var(--st-glow); +} + +/* whole-tile status coloring — set the per-tile vars, everything else reads them */ +.tile.st-ok { + --st: var(--green); + --st-tint: var(--green-tint); + --st-glow: var(--green-glow); +} +.tile.st-run { + --st: var(--yellow); + --st-tint: var(--yellow-tint); + --st-glow: var(--yellow-glow); +} +.tile.st-fail { + --st: var(--red); + --st-tint: var(--red-tint); + --st-glow: var(--red-glow); +} +.tile.st-none { + --st: var(--neutral); + --st-tint: var(--neutral-tint); + --st-glow: var(--neutral-glow); +} + +.tile-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 9px; + cursor: pointer; + border-bottom: 1px solid var(--border-soft); + user-select: none; + background: + var(--sheen), + linear-gradient(90deg, var(--st-tint), var(--st-tint) 45%, transparent 100%); + position: relative; +} + +.tile-header::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--st); + box-shadow: 0 0 8px var(--st-glow); + flex: 0 0 auto; +} + +.tile-header .num { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.tile-header .title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; +} + +.tile-header .badges { + display: flex; + gap: 4px; + flex: 0 0 auto; +} + +.badge { + font-size: 10px; + padding: 1px 7px; + border-radius: 999px; + background: linear-gradient(180deg, var(--elev), var(--panel-2)); + color: var(--muted); + border: 1px solid var(--border); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.badge.draft { + color: var(--yellow); + border-color: var(--yellow); +} + +/* author badge + MINE marker */ +.badge.author { + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.badge.author.mine { + color: var(--accent); + border-color: var(--accent); + font-weight: 700; +} + +/* --- settledness pill --- + The headline "where is this PR?" marker. Order of colors mirrors the + settledness() rule cascade in renderer.js. */ +.pill { + font-size: 10px; + font-weight: 700; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid var(--border); + white-space: nowrap; + letter-spacing: 0.2px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} +.pill.settle-ready-merge { + background: var(--green); + color: #08130b; + border-color: var(--green); + box-shadow: 0 0 10px var(--green-glow); +} +.pill.settle-ready-review { + background: var(--accent); + color: #071322; + border-color: var(--accent); + box-shadow: 0 0 10px var(--accent-soft); +} +.pill.settle-ci-failing, +.pill.settle-conflicting { + background: var(--red); + color: #fff; + border-color: var(--red); + box-shadow: 0 0 10px var(--red-glow); +} +.pill.settle-changes-req { + background: var(--yellow); + color: #1a1200; + border-color: var(--yellow); + box-shadow: 0 0 10px var(--yellow-glow); +} +.pill.settle-draft { + background: var(--elev); + color: var(--muted); +} +.pill.settle-stale { + background: transparent; + color: var(--neutral); + border-color: var(--neutral); +} +.pill.settle-open { + background: var(--elev); + color: var(--text); +} + +/* the webview host */ +.tile-body { + flex: 1 1 auto; + position: relative; + /* status-tinted backdrop shows during load / behind the page */ + background: + linear-gradient(var(--st-tint), var(--st-tint)), var(--tile-body-bg); + min-height: 0; +} + +.tile-body webview { + width: 100%; + height: 100%; + border: 0; + display: flex; +} + +/* placeholder shown before a tile's webview is loaded */ +.placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 6px; + background: + radial-gradient(120px 120px at 50% 40%, var(--st-tint), transparent 70%), + linear-gradient(180deg, var(--panel), var(--panel-2)); + color: var(--muted); + cursor: pointer; + text-align: center; + padding: 12px; +} + +.placeholder .big { + font-size: 24px; + font-weight: 800; + color: var(--text); +} + +/* focus mode: mini-strip variant */ +.tile.mini { + height: auto; + min-height: 0; + flex: 0 0 auto; +} + +.tile.mini .tile-body { + display: none; +} + +.tile.mini .tile-header { + border-bottom: none; +} + +.tile.focused { + border-color: var(--accent); + box-shadow: + 0 0 0 2px var(--accent-soft), + var(--shadow); +} + +.main-pane .tile { + height: 100%; + min-height: 0; +} + +/* --- GitHub sign-in overlay (Path B: sign in once, shared partition) --- */ +#loginOverlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + flex-direction: column; + background: var(--bg); +} + +.login-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: linear-gradient(180deg, var(--panel), var(--panel-2)); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow-sm); +} + +.login-bar .login-title { + font-weight: 700; +} + +.login-bar .login-hint { + color: var(--muted); + font-size: 12px; +} + +.login-bar .spacer { + margin-left: auto; +} + +.login-webview { + flex: 1 1 auto; + border: 0; + background: #fff; +} + +/* --- status message --- */ +.status-msg { + position: fixed; + left: 50%; + bottom: 18px; + transform: translateX(-50%); + background: linear-gradient(180deg, var(--elev), var(--panel-2)); + border: 1px solid var(--border); + color: var(--text); + padding: 9px 15px; + border-radius: 10px; + max-width: 80vw; + box-shadow: var(--shadow); + backdrop-filter: blur(6px); + z-index: 120; +} + +.status-msg.error { + border-color: var(--red); + color: var(--red); +} + +.status-msg:empty { + display: none; +} -- 2.51.2