From 37e62b5172d44d39293263ee265dc13ff56c3fba Mon Sep 17 00:00:00 2001 From: Anton Arnaudov Date: Wed, 26 Aug 2026 15:06:08 +0300 Subject: [PATCH 001/180] feat(desktop): visibility, flexibility and reach across every GitHub surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second wave of the section-page redesign. Nine phases, each landing with its own tests: the app now answers questions it used to send you to a browser for, and stops hiding metadata GitHub already gives us. Pipelines (A1, A2) Mapper consolidation into github/maps.ts, then real log depth: inline, virtualized, ANSI-aware panes with live tail, replacing the small modal. Runs show #runNumber (not the internal id), actor, attempt and durations; jobs show their runner, queue latency and per-step timings. Clone control (E1, E2) Where clones land is a setting, with a per-action destination sheet and a folder-name override on every surface. ghrepo:open returns structured codes, so a collision reopens the sheet prefilled instead of dead-ending. Settings gained a manager for every clone on this machine — origin, badges, reveal, forget, and a typed-confirm delete whose refusal rule is one pure function. Metadata (A3) PRs carry merged-by, requested reviewers, commits, review comments and fork origin; issues distinguish closed-as-completed from closed-as-not-planned; comments carry edited markers, association badges and reactions. subjectRef() parses a notification's subject url, which is what lets Inbox Release and Commit rows open in-app instead of bouncing to github.com. Filters (A4) One facet vocabulary across Actions, PRs, Issues, Inbox and My Work. A facet without a predicate is server-side: Actions' filters re-fetch, so narrowing reaches runs the first page never loaded. Explore (E3, E4, E5) Global GitHub search as a rail page — repositories, people, organizations and code — with full repository pages (routed breadcrumbs, ref switcher, go-to-file over the whole tree) and account pages. Search is metered by a token-bucket guard that refuses before spending and reports how long to wait. Cmd-K searches GitHub live, debounced and generation-checked. Pure logic lives in DOM-free modules so it is node-testable: facetModel, exploreRoutes, searchDebounce, logModel, searchQuery, searchGuard, cloneName. 269 tests pass; both tsconfigs clean; every surface verified in dark and light through the in-repo headless harness (also added here). Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/harness/.gitignore | 2 + apps/desktop/harness/README.md | 21 + apps/desktop/harness/gen.sh | 29 + apps/desktop/harness/shim.js | 634 ++ apps/desktop/harness/shot.sh | 26 + apps/desktop/src/main/aiBridge.ts | 48 +- apps/desktop/src/main/appSettings.ts | 86 + apps/desktop/src/main/autoUpdate.ts | 409 +- apps/desktop/src/main/cloneBridge.ts | 29 +- apps/desktop/src/main/ghRepoOpen.ts | 118 + apps/desktop/src/main/gitBridge.ts | 50 +- apps/desktop/src/main/github/actions.ts | 161 +- apps/desktop/src/main/github/gists.ts | 3 +- apps/desktop/src/main/github/issues.ts | 76 +- apps/desktop/src/main/github/logTail.ts | 47 + apps/desktop/src/main/github/maps.ts | 414 + apps/desktop/src/main/github/myWork.ts | 74 + apps/desktop/src/main/github/notifications.ts | 61 +- apps/desktop/src/main/github/orgs.ts | 133 +- apps/desktop/src/main/github/prs.ts | 56 +- apps/desktop/src/main/github/releases.ts | 40 +- apps/desktop/src/main/github/repoBrowse.ts | 175 + apps/desktop/src/main/github/search.ts | 193 + apps/desktop/src/main/github/searchGuard.ts | 60 + apps/desktop/src/main/github/searchQuery.ts | 84 + apps/desktop/src/main/githubBridge.ts | 107 +- apps/desktop/src/main/githubClient.ts | 272 +- apps/desktop/src/main/githubPaging.ts | 50 + apps/desktop/src/main/githubRemote.ts | 37 + apps/desktop/src/main/localRepos.ts | 243 + apps/desktop/src/main/main.ts | 198 +- apps/desktop/src/main/repoStore.ts | 14 +- apps/desktop/src/renderer/assistant.ts | 16 + apps/desktop/src/renderer/chatRender.ts | 6 +- apps/desktop/src/renderer/cloneDialog.ts | 134 +- apps/desktop/src/renderer/commandPalette.ts | 261 + apps/desktop/src/renderer/destinationSheet.ts | 130 + apps/desktop/src/renderer/dialogs.ts | 76 +- apps/desktop/src/renderer/exploreRoutes.ts | 77 + apps/desktop/src/renderer/facetModel.ts | 92 + apps/desktop/src/renderer/ghOpen.ts | 154 + apps/desktop/src/renderer/graphMount.ts | 5 + apps/desktop/src/renderer/highlight.ts | 112 + apps/desktop/src/renderer/logModel.ts | 234 + apps/desktop/src/renderer/logView.ts | 363 + apps/desktop/src/renderer/peek.ts | 327 + apps/desktop/src/renderer/peeks.ts | 403 + apps/desktop/src/renderer/proseNav.ts | 200 + apps/desktop/src/renderer/renderer.ts | 1214 ++- apps/desktop/src/renderer/repoBrowser.ts | 251 + apps/desktop/src/renderer/searchDebounce.ts | 77 + apps/desktop/src/renderer/styles/app.css | 8987 ++++++++++------- apps/desktop/src/renderer/ui.ts | 79 +- apps/desktop/src/renderer/views/actions.ts | 1503 ++- apps/desktop/src/renderer/views/common.ts | 817 +- apps/desktop/src/renderer/views/explore.ts | 519 + .../desktop/src/renderer/views/exploreRepo.ts | 461 + .../desktop/src/renderer/views/exploreUser.ts | 253 + apps/desktop/src/renderer/views/gists.ts | 742 +- apps/desktop/src/renderer/views/issues.ts | 1113 +- apps/desktop/src/renderer/views/mywork.ts | 192 + .../src/renderer/views/notifications.ts | 248 +- apps/desktop/src/renderer/views/orgs.ts | 432 +- apps/desktop/src/renderer/views/projects.ts | 190 +- apps/desktop/src/renderer/views/prs.ts | 1501 +-- apps/desktop/src/renderer/views/releases.ts | 1014 +- apps/desktop/src/shared/cloneName.ts | 24 + apps/desktop/src/shared/ipc.ts | 422 +- apps/desktop/test/actionsMaps.test.ts | 109 + apps/desktop/test/appSettings.test.ts | 84 + apps/desktop/test/autoUpdate.test.ts | 57 + apps/desktop/test/cloneName.test.ts | 39 + apps/desktop/test/exploreRoutes.test.ts | 99 + apps/desktop/test/facets.test.ts | 105 + apps/desktop/test/ghRepoOpen.test.ts | 138 + apps/desktop/test/githubPaging.test.ts | 45 + apps/desktop/test/identityIpc.test.ts | 69 + apps/desktop/test/itemMaps.test.ts | 162 + apps/desktop/test/localRepos.test.ts | 236 + apps/desktop/test/logModel.test.ts | 91 + apps/desktop/test/logTail.test.ts | 49 + apps/desktop/test/notificationSubject.test.ts | 142 + apps/desktop/test/parseGitHubRemote.test.ts | 36 + apps/desktop/test/refLog.test.ts | 74 + apps/desktop/test/searchDebounce.test.ts | 117 + apps/desktop/test/searchGuard.test.ts | 66 + apps/desktop/test/searchQuery.test.ts | 82 + docs/desktop-redesign.md | 362 + packages/webview-ui/src/graph/commit-graph.ts | 27 +- 89 files changed, 21662 insertions(+), 7106 deletions(-) create mode 100644 apps/desktop/harness/.gitignore create mode 100644 apps/desktop/harness/README.md create mode 100755 apps/desktop/harness/gen.sh create mode 100644 apps/desktop/harness/shim.js create mode 100755 apps/desktop/harness/shot.sh create mode 100644 apps/desktop/src/main/appSettings.ts create mode 100644 apps/desktop/src/main/ghRepoOpen.ts create mode 100644 apps/desktop/src/main/github/logTail.ts create mode 100644 apps/desktop/src/main/github/maps.ts create mode 100644 apps/desktop/src/main/github/myWork.ts create mode 100644 apps/desktop/src/main/github/repoBrowse.ts create mode 100644 apps/desktop/src/main/github/search.ts create mode 100644 apps/desktop/src/main/github/searchGuard.ts create mode 100644 apps/desktop/src/main/github/searchQuery.ts create mode 100644 apps/desktop/src/main/githubPaging.ts create mode 100644 apps/desktop/src/main/githubRemote.ts create mode 100644 apps/desktop/src/main/localRepos.ts create mode 100644 apps/desktop/src/renderer/commandPalette.ts create mode 100644 apps/desktop/src/renderer/destinationSheet.ts create mode 100644 apps/desktop/src/renderer/exploreRoutes.ts create mode 100644 apps/desktop/src/renderer/facetModel.ts create mode 100644 apps/desktop/src/renderer/ghOpen.ts create mode 100644 apps/desktop/src/renderer/highlight.ts create mode 100644 apps/desktop/src/renderer/logModel.ts create mode 100644 apps/desktop/src/renderer/logView.ts create mode 100644 apps/desktop/src/renderer/peek.ts create mode 100644 apps/desktop/src/renderer/peeks.ts create mode 100644 apps/desktop/src/renderer/proseNav.ts create mode 100644 apps/desktop/src/renderer/repoBrowser.ts create mode 100644 apps/desktop/src/renderer/searchDebounce.ts create mode 100644 apps/desktop/src/renderer/views/explore.ts create mode 100644 apps/desktop/src/renderer/views/exploreRepo.ts create mode 100644 apps/desktop/src/renderer/views/exploreUser.ts create mode 100644 apps/desktop/src/renderer/views/mywork.ts create mode 100644 apps/desktop/src/shared/cloneName.ts create mode 100644 apps/desktop/test/actionsMaps.test.ts create mode 100644 apps/desktop/test/appSettings.test.ts create mode 100644 apps/desktop/test/autoUpdate.test.ts create mode 100644 apps/desktop/test/cloneName.test.ts create mode 100644 apps/desktop/test/exploreRoutes.test.ts create mode 100644 apps/desktop/test/facets.test.ts create mode 100644 apps/desktop/test/ghRepoOpen.test.ts create mode 100644 apps/desktop/test/githubPaging.test.ts create mode 100644 apps/desktop/test/identityIpc.test.ts create mode 100644 apps/desktop/test/itemMaps.test.ts create mode 100644 apps/desktop/test/localRepos.test.ts create mode 100644 apps/desktop/test/logModel.test.ts create mode 100644 apps/desktop/test/logTail.test.ts create mode 100644 apps/desktop/test/notificationSubject.test.ts create mode 100644 apps/desktop/test/parseGitHubRemote.test.ts create mode 100644 apps/desktop/test/refLog.test.ts create mode 100644 apps/desktop/test/searchDebounce.test.ts create mode 100644 apps/desktop/test/searchGuard.test.ts create mode 100644 apps/desktop/test/searchQuery.test.ts create mode 100644 docs/desktop-redesign.md diff --git a/apps/desktop/harness/.gitignore b/apps/desktop/harness/.gitignore new file mode 100644 index 0000000..c1de45e --- /dev/null +++ b/apps/desktop/harness/.gitignore @@ -0,0 +1,2 @@ +page/ +out/ diff --git a/apps/desktop/harness/README.md b/apps/desktop/harness/README.md new file mode 100644 index 0000000..61b4969 --- /dev/null +++ b/apps/desktop/harness/README.md @@ -0,0 +1,21 @@ +# Headless render harness + +Runs the WHOLE desktop renderer in plain headless Chrome against realistic +fixtures — no Electron, no GitHub, no repo. This is how UI work on the desktop +app gets **seen** before it ships: every view, both themes, list and detail +states, driven by scripted scenes. + +```sh +cd apps/desktop +node esbuild.js # build the renderer bundles +harness/gen.sh # assemble harness/page from dist + shim.js +harness/shot.sh issues out/issues.png # screenshot a scene +harness/shot.sh 'issues~open31' out/detail.png # …with driver steps +harness/shot.sh 'prs~open106~click:.gh-subtab%5Bdata-sub%3Dfiles%5D' out/files.png +harness/shot.sh issues out/light.png light # light theme +``` + +`shim.js` fakes the preload's `window.gitstudio` bridge (see `shared/ipc.ts`) +with fixtures for the GitStudio repo itself. Unstubbed channels log +`[shim missing] ` to the console and resolve safely — add a fixture +when a view needs one. `harness/page/` and `harness/out/` are generated. diff --git a/apps/desktop/harness/gen.sh b/apps/desktop/harness/gen.sh new file mode 100755 index 0000000..a942390 --- /dev/null +++ b/apps/desktop/harness/gen.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Assemble the headless-render harness page from the built renderer bundles + +# the fixtures shim. Run `npm run build` (or `node esbuild.js`) first. +set -e +HARNESS="$(cd "$(dirname "$0")" && pwd)" +DIST="$(cd "$HARNESS/../dist/renderer" && pwd)" +PAGE="$HARNESS/page" +rm -rf "$PAGE" +mkdir -p "$PAGE" +cp "$DIST/renderer.js" "$DIST/renderer.css" "$DIST/theme-boot.js" "$PAGE/" +cp "$DIST"/brand-*.svg "$DIST"/icon*.png "$PAGE/" 2>/dev/null || true +cp "$HARNESS/shim.js" "$PAGE/shim.js" +cat > "$PAGE/harness.html" <<'HTML' + + + + + + GitStudio harness + + + +
Loading GitStudio…
+ + + + +HTML +echo "harness page at $PAGE/harness.html" diff --git a/apps/desktop/harness/shim.js b/apps/desktop/harness/shim.js new file mode 100644 index 0000000..4cd0394 --- /dev/null +++ b/apps/desktop/harness/shim.js @@ -0,0 +1,634 @@ +// Headless-render harness: fakes the preload's `window.gitstudio` bridge with +// realistic fixtures so the WHOLE desktop renderer runs in plain Chrome. +// Scene selection via ?scene=[.step[.step…]] — steps are driver actions +// run after the repo screen mounts (e.g. "issues.open18" opens issue #18). +(() => { + const now = Date.now(); + const S = (h) => Math.floor(now / 1000) - h * 3600; // epoch-secs, h hours ago + const ISO = (h) => new Date(now - h * 3600e3).toISOString(); + + const params = new URLSearchParams(location.search); + // Steps separated by "~" (not ".") so CSS selectors in click: steps survive. + const scene = (params.get("scene") || "issues").split("~"); + const view = scene[0]; + const steps = scene.slice(1); + const theme = params.get("theme") || "dark"; + + // Pre-seed prefs so the app boots straight into the scene's view, terminal + // collapsed, fixed rail width — deterministic screenshots. + localStorage.setItem( + "gitstudio.ui.prefs", + JSON.stringify({ + currentView: view === "inbox" ? "notifications" : view, + themeMode: theme, + railWidth: 216, + railCollapsed: false, + terminalOpen: false, + }), + ); + + // ── cast ── + const me = "antonarnaudov"; + const u = (login) => ({ login, avatarUrl: null }); + const label = (name, color) => ({ name, color }); + const L = { + bug: label("bug", "d73a4a"), + ux: label("ux", "bfdadc"), + enhancement: label("enhancement", "a2eeef"), + desktop: label("desktop app", "5319e7"), + extension: label("extension", "0e8a16"), + help: label("help wanted", "008672"), + good: label("good first issue", "7057ff"), + perf: label("performance", "fbca04"), + }; + + const issues = [ + { number: 31, title: "Split views make Issues and PRs unreadable on a 13\" screen", state: "open", user: u("mira-holt"), labels: [L.ux, L.desktop], assignees: [u(me)], comments: 6, h: 5, assoc: "MEMBER", + reactions: { total: 9, plusOne: 6, minusOne: 0, laugh: 0, hooray: 2, confused: 0, heart: 1, rocket: 0, eyes: 0 }, + body: "On a MacBook Air the list pane and detail pane fight for space — the list truncates every title and the detail wraps the action buttons onto three rows.\n\n**Expected**: reading an issue should use the full width, like Linear does.\n\n**Actual**: two cramped panes, both scrolling independently." }, + { number: 30, title: "Workflow logs: streaming stops after ~400 lines", state: "open", user: u("s-ohta"), labels: [L.bug], assignees: [], comments: 2, h: 9, + body: "Long jobs stop appending output. Re-opening the run shows the full log, so it's a streaming bug, not a data bug." }, + { number: 29, title: "Notifications: mark-as-done needs a keyboard shortcut", state: "open", user: u("jparks"), labels: [L.enhancement, L.ux], assignees: [u("mira-holt")], comments: 4, h: 16, + body: "Triaging 40 notifications with the mouse is painful. `e` to archive like every inbox, please." }, + { number: 28, title: "Rebase drag-to-reorder flickers when dropping on the last row", state: "open", user: u(me), labels: [L.bug, L.desktop], assignees: [u(me)], comments: 1, h: 28, + body: "Repro:\n1. Open Rebase with 6+ commits\n2. Drag the first commit to the end\n3. Drop marker jumps for a frame\n\nSuspect the placeholder index is off by one when `after === rows.length`." }, + { number: 27, title: "Support per-line staging, not just blocks", state: "open", user: u("dkovachev"), labels: [L.enhancement, L.help], assignees: [], comments: 9, h: 40, + body: "`applySelectedChanges` promotes the whole block a selection touches. JetBrains lets you tick single lines. This needs engine work — see the staging notes in the wiki." }, + { number: 26, title: "Release drafting: attach assets from the app", state: "open", user: u("mira-holt"), labels: [L.enhancement], assignees: [u("s-ohta")], comments: 3, h: 51, + body: "Creating a release works, but uploading a .dmg still means a browser round-trip." }, + { number: 25, title: "Graph: avatars blur on non-retina displays", state: "open", user: u("jparks"), labels: [L.bug, L.perf], assignees: [], comments: 2, h: 70, + body: "Half-pixel alignment again. The gutter drawer draws avatars at y+0.5 on 1x DPR." }, + { number: 23, title: "Onboarding: first-run tour of the six local views", state: "open", user: u("dkovachev"), labels: [L.good, L.ux], assignees: [], comments: 0, h: 90, + body: "" }, + { number: 22, title: "Add a built-in terminal multiplexer", state: "closed", stateReason: "not_planned", user: u("jparks"), labels: [L.enhancement], assignees: [], comments: 3, h: 120, + body: "Out of scope — GitStudio ships one terminal, not a tmux clone." }, + { number: 21, title: "Stash view: show untracked files included in a stash", state: "closed", user: u("s-ohta"), labels: [L.bug], assignees: [u(me)], comments: 5, h: 130, + body: "Fixed by reading the third parent commit when present." }, + { number: 19, title: "Checkout from the graph checks out the commit, not the branch", state: "closed", user: u("mira-holt"), labels: [L.bug], assignees: [u(me)], comments: 8, h: 200, + body: "Detached HEAD surprise. Fixed with checkout-ref." }, + ]; + + const issueComments = { + 31: [ + { id: 1, author: u(me), createdAt: ISO(4), body: "Agreed — this is the #1 usability debt in the app. The plan:\n\n1. Lists go **full width** with richer rows\n2. Opening an item replaces the list with a **full detail view** (Esc / ← goes back)\n3. Properties move to a right rail with inline editing\n\nSame pattern for Issues, PRs, Actions, Releases." }, + { id: 2, author: u("mira-holt"), createdAt: ISO(3.5), updatedAt: ISO(3.4), authorAssociation: "MEMBER", body: "Yes. Also please keep keyboard flow: `↑↓` in the list, `Enter` to open, `Esc` back, `c` to comment." }, + { id: 3, author: u("jparks"), createdAt: ISO(3), authorAssociation: "FIRST_TIME_CONTRIBUTOR", reactions: { total: 4, plusOne: 3, minusOne: 0, laugh: 0, hooray: 0, confused: 0, heart: 1, rocket: 0, eyes: 0 }, body: "While you're in there — the *Open on GitHub* buttons everywhere feel like the app giving up. If the data's already on screen, let me act on it in place." }, + { id: 4, author: u(me), createdAt: ISO(2), body: "> the app giving up\n\nFair. In-app actions become primary; the GitHub link stays as a small escape hatch on every detail view.\n\nDuring Tuesday's GitHub outage the local half of the app kept working fine — the redesign should make the GitHub half feel just as solid." }, + ], + }; + + const prs = [ + { number: 106, title: "desktop: full-page detail views for Issues (kills the split pane)", body: "First section converted to the new list ⇄ detail navigation.\n\n- `sec-*` full-width list rows\n- `det-*` detail page with a property rail\n- Esc / ⌘[ walk back through real history", state: "open", draft: false, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/pull/106", user: u(me), createdAt: ISO(3), updatedAt: ISO(1), head: { ref: "redesign/issues-detail", sha: "a1b2c3d" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [L.desktop, L.ux], comments: 4, additions: 612, deletions: 348, changedFiles: 9, assignees: [u(me), u("mira-holt")], mergedAt: null, closedAt: null, mergedBy: null, reviewComments: 7, commits: 14, requestedReviewers: [u("s-ohta"), u("dkovachev")], milestone: { number: 3, title: "Desktop 1.6" }, authorAssociation: "OWNER", headRepoFullName: null, reactions: { total: 5, plusOne: 4, minusOne: 0, laugh: 0, hooray: 1, confused: 0, heart: 0, rocket: 0, eyes: 0 } }, + { number: 104, title: "actions: stream job logs over IPC with backpressure", body: "", state: "open", draft: false, htmlUrl: "", user: u("s-ohta"), createdAt: ISO(7), updatedAt: ISO(2), head: { ref: "fix/log-stream", sha: "b2c3d4e" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [L.bug], comments: 2, additions: 210, deletions: 64, changedFiles: 4, reviewComments: 2, commits: 5, requestedReviewers: [u(me)], authorAssociation: "MEMBER", headRepoFullName: "s-ohta/gitstudio" }, + { number: 103, title: "engine: per-line staging groundwork (hunk splitting)", body: "", state: "open", draft: true, htmlUrl: "", user: u("dkovachev"), createdAt: ISO(20), updatedAt: ISO(6), head: { ref: "feat/line-staging", sha: "c3d4e5f" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [L.enhancement], comments: 11, additions: 1240, deletions: 180, changedFiles: 17 }, + { number: 101, title: "ci: run desktop tests on windows-latest too", body: "", state: "open", draft: false, htmlUrl: "", user: u("jparks"), createdAt: ISO(30), updatedAt: ISO(26), head: { ref: "ci/windows", sha: "d4e5f6a" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [], comments: 1, additions: 48, deletions: 3, changedFiles: 2 }, + { number: 99, title: "release: extension 1.11.1", body: "", state: "closed", draft: false, htmlUrl: "", user: u(me), createdAt: ISO(50), updatedAt: ISO(44), head: { ref: "release/1.11.1", sha: "e5f6a7b" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [], comments: 0, additions: 12, deletions: 4, changedFiles: 3, mergedAt: ISO(44), closedAt: ISO(44), mergedBy: u("mira-holt"), commits: 3, reviewComments: 1, authorAssociation: "OWNER" }, + ]; + + const prConversation = { + 106: [ + { kind: "comment", author: "mira-holt", createdAt: ISO(2.5), body: "Tried the branch — night and day. Full-width rows read like a real tracker now." }, + { kind: "review", state: "APPROVED", author: "s-ohta", createdAt: ISO(2), body: "Navigation history integration is clean. Ship it." }, + { kind: "comment", author: me, createdAt: ISO(1.2), body: "PRs view converts next on this pattern, then Actions." }, + ], + }; + const prFiles = { + 106: [ + { filename: "apps/desktop/src/renderer/views/issues.ts", status: "modified", additions: 402, deletions: 260 }, + { filename: "apps/desktop/src/renderer/views/common.ts", status: "modified", additions: 118, deletions: 30 }, + { filename: "apps/desktop/src/renderer/styles/app.css", status: "modified", additions: 92, deletions: 58 }, + ], + }; + const prChecks = { + 106: [ + { name: "build / desktop (macos)", status: "completed", conclusion: "success" }, + { name: "build / desktop (windows)", status: "completed", conclusion: "success" }, + { name: "test / renderer", status: "completed", conclusion: "success" }, + { name: "lint", status: "in_progress", conclusion: "" }, + ], + }; + const prCommits = { + 106: [ + { sha: "a1b2c3d4", shortSha: "a1b2c3d", message: "issues: full-page detail as a routed state", author: me, date: ISO(3) }, + { sha: "b2c3d4e5", shortSha: "b2c3d4e", message: "common: sectionList + detailShell primitives", author: me, date: ISO(2.6) }, + ], + }; + + const mkRun = (o) => Object.assign({ + runNumber: 0, runAttempt: 1, displayTitle: o.name, headSha: "9f8e7d6aa11", updatedAt: o.createdAt, + runStartedAt: o.createdAt, actor: u(me), triggeringActor: null, workflowId: 1, + workflowPath: ".github/workflows/desktop.yml", headCommitMessage: "release: extension 1.11.1", + headCommitAuthor: "Anton Arnaudov", pullRequests: [], + }, o); + const runs = [ + mkRun({ id: 9101, runNumber: 412, name: "Desktop CI", displayTitle: "issues: full-page detail as a routed state", status: "in_progress", conclusion: "", branch: "redesign/issues-detail", event: "push", createdAt: ISO(0.4), updatedAt: ISO(0.1), runStartedAt: ISO(0.39), htmlUrl: "", actor: u(me) }), + mkRun({ id: 9100, runNumber: 411, name: "Desktop CI", displayTitle: "release: extension 1.11.1", status: "completed", conclusion: "success", branch: "main", event: "push", createdAt: ISO(3), updatedAt: ISO(2.8), runStartedAt: ISO(2.99), htmlUrl: "", actor: u(me), pullRequests: [{ number: 106 }] }), + mkRun({ id: 9099, runNumber: 233, name: "Extension CI", displayTitle: "test: drive update-refs end-to-end", status: "completed", conclusion: "success", branch: "main", event: "push", createdAt: ISO(5), updatedAt: ISO(4.9), htmlUrl: "", actor: u("mira-holt") }), + mkRun({ id: 9097, runNumber: 410, runAttempt: 2, name: "Desktop CI", displayTitle: "actions: stream job logs over IPC", status: "completed", conclusion: "failure", branch: "fix/log-stream", event: "pull_request", createdAt: ISO(8), updatedAt: ISO(7.7), htmlUrl: "", actor: u("s-ohta"), triggeringActor: u(me), pullRequests: [{ number: 104 }] }), + mkRun({ id: 9095, runNumber: 88, name: "Nightly release", displayTitle: "Nightly release", status: "completed", conclusion: "success", branch: "main", event: "schedule", createdAt: ISO(26), updatedAt: ISO(25.7), htmlUrl: "", actor: u("renderbot") }), + ]; + + const notifications = [ + { id: "n1", title: "Split views make Issues and PRs unreadable on a 13\" screen", type: "Issue", reason: "assign", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(1), unread: true, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/issues/31", subjectKind: "issue", subjectNumber: 31 }, + { id: "n2", title: "desktop: full-page detail views for Issues (kills the split pane)", type: "PullRequest", reason: "review_requested", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(2), unread: true, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/pull/106", subjectKind: "pull", subjectNumber: 106 }, + { id: "n3", title: "v6.2 breaks xterm addon-fit measurements", type: "Issue", reason: "subscribed", repo: "xtermjs/xterm.js", repoAvatarUrl: null, updatedAt: ISO(7), unread: true, htmlUrl: "https://github.com/xtermjs/xterm.js/issues/5120" }, + { id: "n4", title: "Nightly release failed: notarization timeout", type: "CheckSuite", reason: "ci_activity", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(20), unread: false, htmlUrl: "", subjectKind: "other" }, + { id: "n6", title: "Extension 1.11.1", type: "Release", reason: "subscribed", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(40), unread: true, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/releases/51", subjectKind: "release", subjectNumber: 51 }, + { id: "n7", title: "release: extension 1.11.1", type: "Commit", reason: "author", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(41), unread: false, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/commit/9f8e7d6", subjectKind: "commit", subjectSha: "9f8e7d6" }, + { id: "n5", title: "engine: per-line staging groundwork (hunk splitting)", type: "PullRequest", reason: "mention", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(30), unread: false, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/pull/103" }, + ]; + + const releases = [ + { id: 51, tagName: "ext-v1.11.1", targetCommitish: "main", name: "Extension 1.11.1", draft: false, prerelease: false, htmlUrl: "", author: u(me), createdAt: ISO(40), publishedAt: ISO(40), assets: [ { id: 1, name: "gitstudio-1.11.1.vsix", label: null, contentType: "application/zip", size: 4830210, downloadCount: 1240, downloadUrl: "", createdAt: ISO(40), updatedAt: ISO(40) } ], body: "### Fixes\n- Drive the update-refs end-to-end through the shipping runner\n- Graph: keep-alive across view switches" }, + { id: 50, tagName: "desktop-v1.5.1", targetCommitish: "main", name: "Desktop 1.5.1", draft: false, prerelease: false, htmlUrl: "", author: u(me), createdAt: ISO(60), publishedAt: ISO(58), assets: [ { id: 2, name: "GitStudio-1.5.1-arm64.dmg", label: null, contentType: "application/x-apple-diskimage", size: 128400000, downloadCount: 356, downloadUrl: "", createdAt: ISO(58), updatedAt: ISO(58) }, { id: 3, name: "GitStudio-1.5.1-x64.dmg", label: null, contentType: "application/x-apple-diskimage", size: 131200000, downloadCount: 121, downloadUrl: "", createdAt: ISO(58), updatedAt: ISO(58) } ], body: "### Highlights\n- Drag a commit in the graph to reorder it\n- Rebase carries other branches when asked" }, + { id: 49, tagName: "desktop-v1.6.0-beta.1", targetCommitish: "main", name: "Desktop 1.6.0 beta 1", draft: true, prerelease: true, htmlUrl: "", author: u(me), createdAt: ISO(12), publishedAt: null, assets: [], body: "The redesign preview build." }, + ]; + + const branches = [ + { name: "main", current: true, upstream: "origin/main", ahead: 2, behind: 0, subject: "release: extension 1.11.1", date: S(40) }, + { name: "redesign/issues-detail", current: false, upstream: "origin/redesign/issues-detail", ahead: 0, behind: 0, subject: "issues: full-page detail as a routed state", date: S(1) }, + { name: "fix/log-stream", current: false, upstream: undefined, ahead: 0, behind: 0, subject: "actions: stream job logs with backpressure", date: S(8) }, + { name: "feat/line-staging", current: false, upstream: "origin/feat/line-staging", ahead: 3, behind: 5, subject: "engine: hunk splitting groundwork", date: S(20) }, + ]; + + const workflows = [ + { id: 1, name: "Desktop CI", path: ".github/workflows/desktop.yml", state: "active", htmlUrl: "" }, + { id: 2, name: "Extension CI", path: ".github/workflows/extension.yml", state: "active", htmlUrl: "" }, + { id: 3, name: "Nightly release", path: ".github/workflows/nightly.yml", state: "active", htmlUrl: "" }, + ]; + + const orgs = [ + { login: "GitStudioHQ", name: "GitStudio", avatarUrl: null, description: "The open-source Git workspace — desktop app + VS Code extension.", htmlUrl: "https://github.com/GitStudioHQ" }, + ]; + const orgRepos = [ + { name: "gitstudio", fullName: "GitStudioHQ/gitstudio", htmlUrl: "", description: "Monorepo: desktop app, VS Code extension, engine, git-service.", private: false, fork: false, archived: false, language: "TypeScript", stargazersCount: 2140, pushedAt: ISO(1) }, + { name: "gistudio.dev", fullName: "GitStudioHQ/gistudio.dev", htmlUrl: "", description: "Marketing site + error collector.", private: false, fork: false, archived: false, language: "TypeScript", stargazersCount: 84, pushedAt: ISO(60) }, + { name: "design", fullName: "GitStudioHQ/design", htmlUrl: "", description: "Brand + product design assets.", private: true, fork: false, archived: false, language: null, stargazersCount: 0, pushedAt: ISO(120) }, + ]; + + const gists = [ + { id: "g1", description: "GitStudio release checklist", public: false, htmlUrl: "", owner: u(me), createdAt: ISO(300), updatedAt: ISO(48), fileCount: 1, comments: 0, files: [{ filename: "RELEASE.md", language: "Markdown", type: "text/markdown", size: 2400, rawUrl: "", content: "# Release checklist\n\n1. `npm run check-types`\n2. Tag + push\n3. Notarize", truncated: false }] }, + { id: "g2", description: "zsh: git aliases", public: true, htmlUrl: "", owner: u(me), createdAt: ISO(1000), updatedAt: ISO(700), fileCount: 1, comments: 2, files: [{ filename: "aliases.zsh", language: "Shell", type: "text/plain", size: 812, rawUrl: "", content: "alias gs='git status'\nalias gl='git log --oneline -20'", truncated: false }] }, + ]; + + const projects = [ + { id: "p1", number: 1, title: "Desktop redesign", shortDescription: "Linear-grade UX for every surface", url: "", itemCount: 14, closed: false, updatedAt: ISO(2) }, + { id: "p2", number: 2, title: "v2.0", shortDescription: "", url: "", itemCount: 32, closed: false, updatedAt: ISO(50) }, + ]; + const board = { + field: { id: "f1", name: "Status", options: [ { id: "o1", name: "Todo", color: "GRAY" }, { id: "o2", name: "In progress", color: "YELLOW" }, { id: "o3", name: "Done", color: "GREEN" } ] }, + items: [ + { id: "i1", type: "ISSUE", title: "Split views make Issues and PRs unreadable", number: 31, state: "OPEN", url: null, author: "mira-holt", statusOptionId: "o2", statusName: "In progress", updatedAt: ISO(2) }, + { id: "i2", type: "PULL_REQUEST", title: "Full-page detail views for Issues", number: 106, state: "OPEN", url: null, author: me, statusOptionId: "o2", statusName: "In progress", updatedAt: ISO(1) }, + { id: "i3", type: "ISSUE", title: "Notifications keyboard triage", number: 29, state: "OPEN", url: null, author: "jparks", statusOptionId: "o1", statusName: "Todo", updatedAt: ISO(16) }, + { id: "i4", type: "ISSUE", title: "Graph avatars blur on 1x displays", number: 25, state: "OPEN", url: null, author: "jparks", statusOptionId: "o1", statusName: "Todo", updatedAt: ISO(70) }, + { id: "i5", type: "ISSUE", title: "Stash view untracked files", number: 21, state: "CLOSED", url: null, author: "s-ohta", statusOptionId: "o3", statusName: "Done", updatedAt: ISO(130) }, + ], + }; + + const changedFiles = [ + { path: "apps/desktop/src/renderer/views/issues.ts", status: "M", staged: true }, + { path: "apps/desktop/src/renderer/views/common.ts", status: "M", staged: true }, + { path: "apps/desktop/src/renderer/styles/app.css", status: "M", staged: false }, + { path: "apps/desktop/src/renderer/views/prs.ts", status: "M", staged: false }, + { path: "docs/redesign.md", status: "A", staged: false }, + ]; + + const fixtures = { + "repo:current": { root: "/Users/anton/Developer/GitStudioHQ/gitstudio", name: "gitstudio" }, + "repo:recent": [ + { root: "/Users/anton/Developer/GitStudioHQ/gitstudio", name: "gitstudio" }, + { root: "/Users/anton/Developer/GitStudioHQ/gistudio.dev", name: "gistudio.dev" }, + ], + "github:status": { connected: true, login: me, repo: { owner: "GitStudioHQ", repo: "gitstudio" } }, + "sync:status": { branch: "main", upstream: "origin/main", ahead: 2, behind: 0, noUpstream: false }, + "refs:list": branches.map((b) => ({ type: "head", name: b.name, fullName: "refs/heads/" + b.name, sha: "abc123", isCurrent: b.current, upstream: b.upstream })), + "head:get": { detached: false, branch: "main", sha: "9f8e7d6" }, + "branches:list": branches, + "stash:list": [ { sha: "77aa88", ref: "stash@{0}", message: "WIP: palette streaming groups", time: S(30) } ], + "status": changedFiles, + "diff:files": changedFiles, + "notifications:unreadCount": 3, + "notifications:list": notifications, + "issue:list": issues, + "issue:labels": Object.values(L).map((l) => ({ ...l, description: null })), + "issue:milestones": [ + { number: 5, title: "Desktop 1.6 — the redesign", state: "open", openIssues: 6, closedIssues: 3 }, + { number: 4, title: "Extension 1.12", state: "open", openIssues: 2, closedIssues: 1 }, + ], + "pr:list": prs, + "pr:reviewers": [u(me), u("mira-holt"), u("s-ohta"), u("dkovachev"), u("jparks")], + "pr:branches": branches.map((b) => ({ name: b.name, isDefault: b.name === "main" })), + "actions:runs": runs, + "actions:workflows": workflows, + "release:list": releases, + "release:tags": [ { name: "ext-v1.11.1", sha: "e5f6a7b" }, { name: "desktop-v1.5.1", sha: "d4e5f6a" } ], + "orgs:list": orgs, + "gist:list": gists, + "project:list": projects, + "git:identity": { name: "Anton Arnaudov", email: "anton@gitstudio.dev" }, + "github:myWork": [ + { kind: "review-requested", type: "pr", number: 104, title: "actions: stream job logs over IPC with backpressure", state: "open", draft: false, updatedAt: ISO(2), comments: 2, author: "s-ohta" }, + { kind: "review-requested", type: "pr", number: 103, title: "engine: per-line staging groundwork (hunk splitting)", state: "open", draft: true, updatedAt: ISO(6), comments: 11, author: "dkovachev" }, + { kind: "assigned", type: "issue", number: 31, title: "Split views make Issues and PRs unreadable on a 13\" screen", state: "open", draft: false, updatedAt: ISO(2), comments: 6, author: "mira-holt" }, + { kind: "assigned", type: "issue", number: 28, title: "Rebase drag-to-reorder flickers when dropping on the last row", state: "open", draft: false, updatedAt: ISO(28), comments: 1, author: me }, + { kind: "my-prs", type: "pr", number: 106, title: "desktop: full-page detail views for Issues (kills the split pane)", state: "open", draft: false, updatedAt: ISO(1), comments: 4, author: me }, + { kind: "mentions", type: "issue", number: 27, title: "Support per-line staging, not just blocks", state: "open", draft: false, updatedAt: ISO(40), comments: 9, author: "dkovachev" }, + ], + "app:info": { version: "1.5.1", platform: "darwin" }, + "github:userInfo": { login: "antonarnaudov", name: "Anton Arnaudov", avatarUrl: null, bio: "Building GitStudio — the open-source Git workspace.", company: "@GitStudioHQ", location: "Sofia, Bulgaria", blog: "gistudio.dev", htmlUrl: "https://github.com/antonarnaudov", followers: 412, following: 63, publicRepos: 24, createdAt: ISO(3000), type: "User", twitter: "antonarnaudov", email: null }, + "graph:load": { rows: [], head: "9f8e7d6", totalColumns: 1, hasMore: false, nextSkip: 0 }, + "repo:headCommit": { sha: "9f8e7d6", shortSha: "9f8e7d", author: "Anton Arnaudov", authorEmail: "anton@gitstudio.dev", date: S(40), subject: "release: extension 1.11.1", message: "release: extension 1.11.1", total: 512 }, + "repo:tree": [], + "ssh:keys": [], + "worktree:list": [], + }; + + // E1: mutable settings so the Repositories card + destination sheet are + // exercisable in the harness (Change… picks a canned folder). + const settingsState = { cloneDir: null, askWhereEveryTime: params.get("ask") === "1" }; + const SETTINGS_DEFAULT = "/Users/demo/GitStudio"; + const settingsView = () => { + const dir = settingsState.cloneDir || SETTINGS_DEFAULT; + return { + cloneDir: dir, + cloneDirDisplay: dir.startsWith("/Users/demo") ? "~" + dir.slice("/Users/demo".length) : dir, + cloneDirIsDefault: !settingsState.cloneDir, + askWhereEveryTime: settingsState.askWhereEveryTime, + }; + }; + + let localCopies = [ + { + root: "/Users/demo/GitStudio/gitstudio", + name: "gitstudio", + origin: "GitStudioHQ/gitstudio", + managed: true, + recent: true, + current: true, + missing: false, + }, + { + root: "/Users/demo/GitStudio/gistudio.dev", + name: "gistudio.dev", + origin: "GitStudioHQ/gistudio.dev", + managed: true, + recent: true, + current: false, + missing: false, + }, + { + root: "/Users/demo/GitStudio/design", + name: "design", + origin: "GitStudioHQ/design", + managed: true, + recent: false, + current: false, + missing: false, + }, + { + root: "/Users/demo/Code/experiments", + name: "experiments", + origin: "antonarnaudov/experiments", + managed: false, + recent: true, + current: false, + missing: false, + }, + { + root: "/Users/demo/Code/old-prototype", + name: "old-prototype", + managed: false, + recent: true, + current: false, + missing: true, + }, + ]; + + const dynamic = { + "settings:get": () => settingsView(), + "settings:update": (patch) => { + if (patch && patch.cloneDir === null) settingsState.cloneDir = null; + else if (patch && typeof patch.cloneDir === "string") settingsState.cloneDir = patch.cloneDir; + if (patch && typeof patch.askWhereEveryTime === "boolean") settingsState.askWhereEveryTime = patch.askWhereEveryTime; + return settingsView(); + }, + "settings:pickCloneDir": () => { + settingsState.cloneDir = "/Volumes/Work/src"; + return settingsView(); + }, + "clone:pickDir": () => "/Volumes/Work/src", + "orgs:repoDetail": (fullName) => ({ + fullName, + description: "The open-source Git workspace — desktop app + VS Code extension.", + htmlUrl: "https://github.com/" + fullName, + cloneUrl: "https://github.com/" + fullName + ".git", + sshUrl: "git@github.com:" + fullName + ".git", + defaultBranch: "main", + openIssuesCount: 31, + forksCount: 96, + stargazersCount: 2140, + topics: ["git", "electron", "typescript", "vscode-extension"], + license: "MIT", + language: "TypeScript", + private: false, + archived: false, + fork: false, + pushedAt: ISO(1), + createdAt: ISO(900), + homepage: "https://gistudio.dev", + }), + // E4: entity pages — remote tree/file/readme at a ref, branches, paths. + "ghrepo:branches": () => [ + { name: "main", sha: "9f8e7d6", protected: true }, + { name: "redesign/issues-detail", sha: "a1b2c3d", protected: false }, + { name: "fix/log-stream", sha: "b2c3d4e", protected: false }, + ], + "ghrepo:paths": () => ({ + paths: [ + "README.md", + "package.json", + "apps/desktop/src/main/main.ts", + "apps/desktop/src/renderer/renderer.ts", + "apps/desktop/src/renderer/logView.ts", + "apps/desktop/src/renderer/views/explore.ts", + "packages/engine/src/lane.ts", + ], + truncated: false, + total: 7, + }), + "ghrepo:tree": (req) => { + if (!req.path) { + return [ + { name: "apps", path: "apps", type: "dir" }, + { name: "packages", path: "packages", type: "dir" }, + { name: "docs", path: "docs", type: "dir" }, + { name: "README.md", path: "README.md", type: "file", size: 8214 }, + { name: "package.json", path: "package.json", type: "file", size: 1620 }, + ]; + } + if (req.path === "apps") return [{ name: "desktop", path: "apps/desktop", type: "dir" }]; + return [{ name: "index.ts", path: req.path + "/index.ts", type: "file", size: 420 }]; + }, + "ghrepo:file": (req) => ({ + path: req.path, + text: "export function createLogPane(o: LogPaneOpts): LogPane {\n const el = document.createElement(\"div\");\n el.className = \"log-pane\";\n return { el, append, reset, finish };\n}\n", + truncated: false, + binary: false, + size: 420, + }), + "ghrepo:readme": () => ({ + name: "README.md", + text: "# GitStudio\n\nThe open-source Git workspace — a desktop app and a VS Code extension that share one engine.\n\n## Why\n\nBecause a Git client should let you *read* a repository, not just launch a browser.\n\n- Full-page sections, no split panes\n- Everything GitHub does, in the app\n- Works when GitHub doesn't\n", + }), + "users:repos": () => [ + { name: "gitstudio", fullName: "GitStudioHQ/gitstudio", htmlUrl: "", description: "The open-source Git workspace — desktop app + VS Code extension.", private: false, fork: false, archived: false, language: "TypeScript", stargazersCount: 2140, pushedAt: ISO(1) }, + { name: "gistudio.dev", fullName: "GitStudioHQ/gistudio.dev", htmlUrl: "", description: "Marketing site and the error-report collector.", private: false, fork: false, archived: false, language: "TypeScript", stargazersCount: 84, pushedAt: ISO(5) }, + { name: "dotfiles", fullName: "antonarnaudov/dotfiles", htmlUrl: "", description: null, private: false, fork: false, archived: false, language: "Shell", stargazersCount: 3, pushedAt: ISO(200) }, + ], + "users:orgs": () => [ + { login: "GitStudioHQ", name: "GitStudio", avatarUrl: null, description: "The open-source Git workspace.", htmlUrl: "https://github.com/GitStudioHQ" }, + ], + // E3: global search. Returns a canned page shaped by the query so the + // Explore surfaces (results, sort, load-more footer) are exercisable. + "search:repos": (req) => { + const q = (req.query || "").toLowerCase(); + const all = [ + { id: 1, fullName: "GitStudioHQ/gitstudio", owner: "GitStudioHQ", ownerAvatarUrl: null, description: "The open-source Git workspace — desktop app + VS Code extension.", language: "TypeScript", stars: 2140, forks: 96, openIssues: 31, updatedAt: ISO(2), pushedAt: ISO(1), private: false, fork: false, archived: false, topics: ["git", "electron"], license: "MIT", htmlUrl: "https://github.com/GitStudioHQ/gitstudio", defaultBranch: "main" }, + { id: 2, fullName: "libgit2/libgit2", owner: "libgit2", ownerAvatarUrl: null, description: "A cross-platform, linkable library implementation of Git.", language: "C", stars: 9800, forks: 2400, openIssues: 380, updatedAt: ISO(20), pushedAt: ISO(6), private: false, fork: false, archived: false, topics: ["git"], license: "GPL-2.0", htmlUrl: "https://github.com/libgit2/libgit2", defaultBranch: "main" }, + { id: 3, fullName: "desktop/desktop", owner: "desktop", ownerAvatarUrl: null, description: "Focus on what matters instead of fighting with Git.", language: "TypeScript", stars: 12400, forks: 9600, openIssues: 1200, updatedAt: ISO(30), pushedAt: ISO(12), private: false, fork: false, archived: false, topics: ["git", "electron"], license: "MIT", htmlUrl: "https://github.com/desktop/desktop", defaultBranch: "development" }, + { id: 4, fullName: "jesseduffield/lazygit", owner: "jesseduffield", ownerAvatarUrl: null, description: "Simple terminal UI for git commands.", language: "Go", stars: 48200, forks: 1700, openIssues: 420, updatedAt: ISO(9), pushedAt: ISO(3), private: false, fork: false, archived: false, topics: ["git", "tui"], license: "MIT", htmlUrl: "https://github.com/jesseduffield/lazygit", defaultBranch: "master" }, + { id: 5, fullName: "old/archived-git-tool", owner: "old", ownerAvatarUrl: null, description: "No longer maintained.", language: "Ruby", stars: 12, forks: 3, openIssues: 0, updatedAt: ISO(900), pushedAt: ISO(900), private: false, fork: false, archived: true, htmlUrl: "https://github.com/old/archived-git-tool", topics: [], license: null, defaultBranch: "master" }, + ].filter((r) => !q || (r.fullName + " " + (r.description || "")).toLowerCase().includes(q.split(" ")[0])); + return { items: all, totalCount: 1284, incomplete: false, hasMore: true }; + }, + "search:users": (req) => { + const org = req.kind === "orgs"; + const items = org + ? [ + { login: "GitStudioHQ", avatarUrl: null, htmlUrl: "https://github.com/GitStudioHQ", type: "Organization" }, + { login: "github", avatarUrl: null, htmlUrl: "https://github.com/github", type: "Organization" }, + { login: "libgit2", avatarUrl: null, htmlUrl: "https://github.com/libgit2", type: "Organization" }, + ] + : [ + { login: "antonarnaudov", avatarUrl: null, htmlUrl: "https://github.com/antonarnaudov", type: "User" }, + { login: "mira-holt", avatarUrl: null, htmlUrl: "https://github.com/mira-holt", type: "User" }, + { login: "s-ohta", avatarUrl: null, htmlUrl: "https://github.com/s-ohta", type: "User" }, + ]; + return { items, totalCount: items.length, incomplete: false, hasMore: false }; + }, + "search:code": (req) => ({ + items: [ + { name: "logView.ts", path: "apps/desktop/src/renderer/logView.ts", repoFullName: "GitStudioHQ/gitstudio", htmlUrl: "https://github.com/GitStudioHQ/gitstudio", fragments: ["export function createLogPane(o: LogPaneOpts): LogPane {", " const el = document.createElement(\"div\");"] }, + { name: "index.ts", path: "src/git/index.ts", repoFullName: "libgit2/libgit2", htmlUrl: "https://github.com/libgit2/libgit2", fragments: ["int git_repository_open(git_repository **out, const char *path)"] }, + ], + totalCount: 2, + incomplete: false, + hasMore: false, + }), + // E2: the local-copies manager. Mutable so Remove/Delete are exercisable. + "repos:local": () => localCopies, + "repos:reveal": () => true, + "repos:removeRecent": (root) => { + const hit = localCopies.find((c) => c.root === root); + if (hit) hit.recent = false; + localCopies = localCopies.filter((c) => c.recent || c.managed); + return localCopies; + }, + "repos:trash": (root) => { + localCopies = localCopies.filter((c) => c.root !== root); + return { ok: true, changed: true }; + }, + "issue:detail": (n) => { + const it = issues.find((i) => i.number === n); + if (!it) return undefined; + return { issue: iss(it), comments: issueComments[n] || [], assignees: it.assignees.map((a) => a.login) }; + }, + "pr:detail": (n) => { + const pr = prs.find((p) => p.number === n); + if (!pr) return undefined; + return { pr, files: prFiles[n] || [], checks: n === 106 ? "pending" : "success" }; + }, + "pr:conversation": (n) => prConversation[n] || [], + "pr:files": (n) => prFiles[n] || [], + "pr:checks": (n) => prChecks[n] || [], + "pr:commits": (n) => prCommits[n] || [], + "actions:runDetail": (id) => ({ + run: runs.find((r) => r.id === id) || runs[0], + jobs: [ + { id: 1, runId: id, runAttempt: 1, name: "build (macos-latest)", status: "completed", conclusion: "success", htmlUrl: "", createdAt: ISO(0.42), startedAt: ISO(0.4), completedAt: ISO(0.2), runnerName: "GitHub Actions 8", runnerGroupName: "Default", labels: ["macos-latest"], workflowName: "Desktop CI", headBranch: "main", steps: [ + { name: "Checkout", status: "completed", conclusion: "success", number: 1, startedAt: ISO(0.4), completedAt: ISO(0.395) }, + { name: "npm ci", status: "completed", conclusion: "success", number: 2, startedAt: ISO(0.395), completedAt: ISO(0.33) }, + { name: "Build bundles", status: "completed", conclusion: "success", number: 3, startedAt: ISO(0.33), completedAt: ISO(0.25) }, + { name: "Renderer tests", status: "completed", conclusion: "success", number: 4, startedAt: ISO(0.25), completedAt: ISO(0.2) }, + ] }, + { id: 2, runId: id, runAttempt: 1, name: "build (windows-latest)", status: "in_progress", conclusion: "", htmlUrl: "", createdAt: ISO(0.42), startedAt: ISO(0.3), completedAt: "", runnerName: "", runnerGroupName: "", labels: ["windows-latest"], workflowName: "Desktop CI", headBranch: "main", steps: [ + { name: "Checkout", status: "completed", conclusion: "success", number: 1, startedAt: ISO(0.3), completedAt: ISO(0.29) }, + { name: "npm ci", status: "in_progress", conclusion: "", number: 2, startedAt: ISO(0.29), completedAt: "" }, + ] }, + ], + }), + "orgs:repos": () => orgRepos, + "orgs:teams": () => [ { name: "Core", slug: "core", description: "Maintainers", privacy: "closed", htmlUrl: "" } ], + "orgs:members": () => [u(me), u("mira-holt"), u("s-ohta"), u("dkovachev"), u("jparks")].map((p) => ({ ...p, htmlUrl: "" })), + "project:board": () => board, + "ref:log": () => [ + { sha: "a1", shortSha: "a1b2c3d", subject: "issues: full-page detail as a routed state", author: "Anton Arnaudov", date: S(1) }, + { sha: "b2", shortSha: "b2c3d4e", subject: "common: sectionList + detailShell primitives", author: "Anton Arnaudov", date: S(3) }, + { sha: "c3", shortSha: "c3d4e5f", subject: "css: list + detail tokens", author: "Mira Holt", date: S(6) }, + ], + "gist:detail": (id) => gists.find((g) => g.id === id), + "release:detail": (id) => releases.find((r) => r.id === id), + "pr:reviewThreads": () => [ + { id: "t1", path: "apps/desktop/src/renderer/views/issues.ts", line: 42, isResolved: false, isOutdated: false, + comments: [ + { id: "c1", author: u("mira-holt"), body: "Could this reuse `secRow` from common.ts instead of building the row by hand?", createdAt: ISO(1.4) }, + { id: "c2", author: u(me), body: "Good catch — switched to `secRow` in the next push.", createdAt: ISO(1.1) }, + ] }, + { id: "t2", path: "apps/desktop/src/renderer/views/issues.ts", line: 118, isResolved: true, isOutdated: false, + comments: [ { id: "c3", author: u("s-ohta"), body: "This `replaceChildren` runs twice on refresh.", createdAt: ISO(2) } ] }, + ], + "actions:jobLogChunk": (req) => { + const TS = "2026-08-25T10:00:42.1234567Z "; + const lines = []; + lines.push(TS + "##[group]Run actions/checkout@v4"); + lines.push(TS + "Syncing repository: GitStudioHQ/gitstudio"); + lines.push(TS + "\u001b[36;1mgit version 2.47.0\u001b[0m"); + lines.push(TS + "##[endgroup]"); + lines.push(TS + "##[group]Run npm ci"); + for (let i = 0; i < 40; i++) lines.push(TS + "npm \u001b[2mtiming\u001b[0m package " + i + " fetched in \u001b[32m" + (20 + i) + "ms\u001b[0m"); + lines.push(TS + "npm \u001b[33mWARN\u001b[0m deprecated example@1.0.0"); + lines.push(TS + "##[endgroup]"); + lines.push(TS + "##[group]Build bundles"); + lines.push(TS + "\u001b[1m[build]\u001b[0m started"); + for (let i = 0; i < 60; i++) lines.push(TS + " bundling module " + i + "/60 …"); + lines.push(TS + "\u001b[32m[build] finished\u001b[0m"); + lines.push(TS + "##[endgroup]"); + lines.push(TS + "##[error]Process completed with exit code 1."); + const full = lines.join("\n") + "\n"; + const off = Math.max(0, req.offset || 0); + return { text: full.slice(off), totalLength: full.length, reset: off > full.length, truncated: false }; + }, + "pr:fileDiff": (req) => ({ + path: req.path, + leftLabel: "main", + rightLabel: "redesign/issues-detail", + leftText: 'const view = ghTwoPane();\nconst listEl = view.listEl;\nconst detail = view.detailEl;\n\nfunction select(it, row) {\n row.classList.add("active");\n showDetail(detail, it.number);\n}\n', + rightText: 'const { view, listEl } = sectionList();\n\nfunction open(it) {\n nav("issues", { number: it.number });\n}\n', + conflicted: false, + }), + }; + + // IssueInfo body normalizer (fixtures store a trimmed shape). + function iss(it) { + return { number: it.number, title: it.title, body: it.body || "", state: it.state, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/issues/" + it.number, user: it.user, createdAt: ISO(it.h), updatedAt: ISO(it.h / 2), comments: it.comments, labels: it.labels, assignees: it.assignees, milestone: it.milestone || null, + closedAt: it.state === "closed" ? ISO(it.h / 3) : null, + closedBy: it.state === "closed" ? u(me) : null, + stateReason: it.stateReason || (it.state === "closed" ? "completed" : null), + authorAssociation: it.assoc || "CONTRIBUTOR", + reactions: it.reactions }; + } + // The list handler must return IssueInfo shapes too. + fixtures["issue:list"] = issues.map(iss); + // ?many=1 → synthesize a capped-size list (300) to exercise the cap notice. + if (params.get("many")) { + const synth = []; + for (let i = 0; i < 300; i++) { + synth.push(iss({ number: 400 + i, title: `Synthetic issue #${400 + i} for pagination testing`, state: "open", user: u("renderbot"), labels: i % 3 ? [L.bug] : [L.ux], assignees: [], comments: i % 7, h: i + 1 })); + } + fixtures["issue:list"] = synth; + } + + const missing = new Set(); + window.gitstudio = { + invoke(channel, payload) { + if (channel in dynamic) { + try { return Promise.resolve(dynamic[channel](payload)); } catch (e) { return Promise.reject(e); } + } + if (channel in fixtures) { + // issue:list respects the state filter so Open/Closed/All work. + if (channel === "issue:list" && payload && payload.state && payload.state !== "all") { + return Promise.resolve(fixtures[channel].filter((i) => i.state === payload.state)); + } + return Promise.resolve(fixtures[channel]); + } + missing.add(channel); + console.error("[shim missing]", channel, JSON.stringify(payload)); + // Mutations: pretend success so flows continue; reads: undefined. + if (/:(set|create|edit|comment|merge|rerun|cancel|dispatch|markRead|apply|update|upload|delete|approve|review)/.test(channel)) { + return Promise.resolve({ ok: true, changed: false }); + } + return Promise.resolve(undefined); + }, + on() { return () => {}; }, + }; + + // ── scene driver ── + const q = (sel) => document.querySelector(sel); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + async function until(fn, timeout = 8000) { + const t0 = Date.now(); + for (;;) { + const v = fn(); + if (v) return v; + if (Date.now() - t0 > timeout) throw new Error("timeout: " + fn); + await wait(60); + } + } + async function drive() { + await until(() => q(".screen.repo")); + for (const step of steps) { + await wait(250); + if (step === "bell") { + const bell = q('[aria-label*="otification"], .topbar-bell, [title*="otification"]'); + if (bell) bell.click(); + } else if (step.startsWith("open")) { + const num = step.slice(4); + const row = await until(() => q(`[data-num="${num}"]`)); + row.click(); + } else if (step.startsWith("click:")) { + const sel = decodeURIComponent(step.slice(6)); + const elx = await until(() => q(sel)); + elx.click(); + } else if (step.startsWith("scroll:")) { + const sel = decodeURIComponent(step.slice(7)); + const target = await until(() => q(sel)); + target.scrollIntoView({ block: "center" }); + } else if (step.startsWith("type:")) { + // Type into the focused input (palette, search fields). + const val = decodeURIComponent(step.slice(5)); + const inp = document.activeElement && document.activeElement.tagName === "INPUT" + ? document.activeElement + : await until(() => q("input:focus") || q(".cmdk-card input") || q("input")); + inp.value = val; + inp.dispatchEvent(new Event("input", { bubbles: true })); + } else if (step.startsWith("text:")) { + // Click the first button/row whose visible text contains the needle. + const needle = decodeURIComponent(step.slice(5)).toLowerCase(); + const hit = await until(() => + Array.from(document.querySelectorAll("button, [role=option], .list-row, .cmdk-row")).find( + (b) => (b.textContent || "").toLowerCase().includes(needle), + ), + ); + hit.click(); + } else if (step === "palette") { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + } else if (step === "esc") { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); + } else if (step.startsWith("key:")) { + const key = decodeURIComponent(step.slice(4)); + // Dispatch on the FOCUSED element when there is one: a real keypress + // goes to what has focus and bubbles up, which is what handlers on an + // input (Enter-to-search) actually listen for. + const target = document.activeElement && document.activeElement !== document.body + ? document.activeElement + : window; + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); + } + await wait(350); + } + await wait(600); + document.title = "SCENE-READY"; + } + window.addEventListener("DOMContentLoaded", () => { drive().catch((e) => console.error("[driver]", e)); }); +})(); diff --git a/apps/desktop/harness/shot.sh b/apps/desktop/harness/shot.sh new file mode 100755 index 0000000..42150f0 --- /dev/null +++ b/apps/desktop/harness/shot.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# shot.sh [theme] — screenshot one harness scene headlessly. +# +# A scene is "[~step[~step…]]" — steps run after the repo screen mounts: +# open click the list row with data-num="" (open a detail) +# click: click the first match (URL-encode [ ] = as %5B %5D %3D) +# esc dispatch Escape (detail → list) +# palette open the ⌘K palette +# bell open the notifications popover +# Examples: +# ./shot.sh issues out/issues.png +# ./shot.sh 'issues~open31' out/detail.png light +# ./shot.sh 'prs~open106~click:.gh-subtab%5Bdata-sub%3Dfiles%5D' out/files.png +set -e +HARNESS="$(cd "$(dirname "$0")" && pwd)" +SCENE="${1:-issues}" +OUT="${2:-$HARNESS/out/$SCENE.png}" +THEME="${3:-dark}" +mkdir -p "$(dirname "$OUT")" +"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --headless --disable-gpu --hide-scrollbars \ + --window-size=1600,1000 --force-device-scale-factor=2 \ + --virtual-time-budget=9000 \ + --screenshot="$OUT" \ + "file://$HARNESS/page/harness.html?scene=$SCENE&theme=$THEME" 2>&1 | grep -viE 'devtools|gpu|fontations|dawn|install' || true +echo "wrote $OUT" diff --git a/apps/desktop/src/main/aiBridge.ts b/apps/desktop/src/main/aiBridge.ts index 2bfe3cf..3c08aff 100644 --- a/apps/desktop/src/main/aiBridge.ts +++ b/apps/desktop/src/main/aiBridge.ts @@ -15,9 +15,8 @@ // the trade-off that replaces it, and migrateLegacyKeys() for the one-way import // of keys written by the old scheme. -import { app, safeStorage } from "electron"; +import { app } from "electron"; import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; -import { existsSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { SecretStore } from "@gitstudio/secret-store/secretStore"; @@ -149,7 +148,10 @@ export class AiBridge { * row, so it runs whenever the settings view renders — it must never decrypt. */ private hasKeyFile(id: string): boolean { - return this.secrets().has(id) || existsSync(this.legacyKeyPath(id)); + // Only the keychain-free store counts: a leftover pre-1.4 keyring blob is + // unreadable by design (see loadKey), so it must not make a connection + // look `usable` when every real request would then fail. + return this.secrets().has(id); } private async loadKey(id: string): Promise { @@ -157,39 +159,13 @@ export class AiBridge { if (current !== undefined) { return current; } - return this.adoptLegacyKey(id); - } - - /** - * Move a pre-1.4 safeStorage key into the keychain-free store, once. - * - * This is the last code path in the app that can raise an OS password prompt, - * so it is deliberately confined to `loadKey` — reached only when the user - * runs an AI task or opens the model picker, never from a status probe or - * startup. After it succeeds the legacy blob is deleted, so a given key can - * cost at most one prompt, ever. If the user dismisses that prompt the - * decrypt throws and AI just stays unavailable; nothing is lost and the next - * attempt can try again. - */ - private async adoptLegacyKey(id: string): Promise { - const legacy = this.legacyKeyPath(id); - let buf: Buffer; - try { - buf = await readFile(legacy); - } catch { - return undefined; // no key at all - } - let key: string; - try { - key = safeStorage.isEncryptionAvailable() - ? safeStorage.decryptString(buf) - : buf.toString("utf8"); - } catch { - return undefined; - } - await this.secrets().set(id, key); - await unlink(legacy).catch(() => {}); - return key; + // NO KEYRING, EVER. A pre-1.4 safeStorage blob could only be read through + // the OS keychain, whose ACL is bound to the app's code signature — every + // rebuilt/re-signed binary raised the macOS password prompt again. That + // migration is gone: any leftover blob is deleted unread; re-enter the key + // once in Settings → AI models and it lands in the prompt-free store. + await unlink(this.legacyKeyPath(id)).catch(() => {}); + return undefined; } private async storeKey(id: string, key: string): Promise { diff --git a/apps/desktop/src/main/appSettings.ts b/apps/desktop/src/main/appSettings.ts new file mode 100644 index 0000000..841e8cd --- /dev/null +++ b/apps/desktop/src/main/appSettings.ts @@ -0,0 +1,86 @@ +// App-wide user settings, persisted as userData/app-settings.json (the same +// tolerant-JSON pattern as errorReporter's store). Electron-free by design — +// every path is injected — so the store unit-tests under plain node. +// +// Today it holds the clone preferences (the "Repositories" Settings card): +// • cloneDir — where one-click opens and clones land by default +// • askWhereEveryTime — force the destination sheet on every clone/open + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { AppSettingsView } from "../shared/ipc"; + +interface Persisted { + cloneDir?: string; + askWhereEveryTime?: boolean; +} + +export class AppSettings { + private constructor( + private readonly file: string, + private readonly data: Persisted, + private readonly defaultCloneDir: string, + private readonly home: string, + ) {} + + /** + * Load (or initialize) the settings store. `userDataDir` is where the JSON + * lives; `defaultCloneDir`/`home` come from Electron in production and from + * temp dirs in tests. + */ + static async load( + userDataDir: string, + o: { defaultCloneDir: string; home: string }, + ): Promise { + const file = join(userDataDir, "app-settings.json"); + let data: Persisted = {}; + try { + const raw = JSON.parse(await readFile(file, "utf8")) as unknown; + if (raw && typeof raw === "object") { + const r = raw as Record; + if (typeof r.cloneDir === "string" && r.cloneDir.trim()) data.cloneDir = r.cloneDir; + if (typeof r.askWhereEveryTime === "boolean") data.askWhereEveryTime = r.askWhereEveryTime; + } + } catch { + data = {}; // missing / unreadable / malformed — start fresh + } + return new AppSettings(file, data, o.defaultCloneDir, o.home); + } + + /** The effective default clone parent — the preference, or the built-in. */ + effectiveCloneDir(): string { + return this.data.cloneDir ?? this.defaultCloneDir; + } + + askWhereEveryTime(): boolean { + return this.data.askWhereEveryTime ?? false; + } + + view(): AppSettingsView { + const dir = this.effectiveCloneDir(); + return { + cloneDir: dir, + cloneDirDisplay: dir.startsWith(this.home) ? `~${dir.slice(this.home.length)}` : dir, + cloneDirIsDefault: this.data.cloneDir === undefined, + askWhereEveryTime: this.askWhereEveryTime(), + }; + } + + /** Apply a patch (null cloneDir resets to the default) and persist. */ + async update(patch: { cloneDir?: string | null; askWhereEveryTime?: boolean }): Promise { + if (patch.cloneDir === null) delete this.data.cloneDir; + else if (typeof patch.cloneDir === "string" && patch.cloneDir.trim()) { + this.data.cloneDir = patch.cloneDir; + } + if (typeof patch.askWhereEveryTime === "boolean") { + this.data.askWhereEveryTime = patch.askWhereEveryTime; + } + try { + await mkdir(join(this.file, ".."), { recursive: true }); + await writeFile(this.file, JSON.stringify(this.data, null, 2), "utf8"); + } catch { + /* best-effort — settings still apply for this session */ + } + return this.view(); + } +} diff --git a/apps/desktop/src/main/autoUpdate.ts b/apps/desktop/src/main/autoUpdate.ts index 18e8d57..6ed45f2 100644 --- a/apps/desktop/src/main/autoUpdate.ts +++ b/apps/desktop/src/main/autoUpdate.ts @@ -1,28 +1,52 @@ -// Update handling. Two mechanisms, because the platforms genuinely differ: +// Update handling: poll → ask → pull → apply, with the user in charge. // -// Windows / Linux(AppImage) — electron-updater downloads in the background -// and installs on quit. It now TELLS the user when an update is staged, -// instead of replacing the app under them with no indication at all. +// The manager polls for a newer app-v* release (on startup and every few +// hours), tells the RENDERER when one exists (update:available), and then +// waits: nothing downloads until the user confirms in-app. After the +// confirmed download it reports update:ready, and `update:install` applies it. // -// macOS — Squirrel.Mac cannot apply an update to an unsigned build, and both -// arch runners emit an identically-named latest-mac.yml, so the release -// deliberately ships no mac feed (see release-desktop.yml). Rather than -// stay silent — which left mac users on an old build forever with no hint -// a newer one existed — ask GitHub what the latest desktop release is and -// post one notification pointing at the download. +// Two apply mechanisms, because the platforms genuinely differ: +// +// Windows / Linux(AppImage) — electron-updater downloads the delta and +// quitAndInstall() restarts straight into the new version. If the user +// declines the restart, it still installs on quit (autoInstallOnAppQuit). +// +// macOS — Squirrel.Mac cannot apply an update to an unsigned build, and the +// release ships no mac feed (see release-desktop.yml). Instead the manager +// downloads the right DMG from the GitHub release into ~/Downloads (with +// progress) and `update:install` opens it — one drag to Applications. // // Everything here is best-effort: no network, no releases, or no // electron-updater must never affect startup. -import { app, Notification, shell } from "electron"; +import { app, shell } from "electron"; +import { createWriteStream } from "node:fs"; +import { rename, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import type { IpcEvents, UpdateCheckResult } from "../shared/ipc"; export interface AutoUpdateOptions { isDev: boolean; + /** Push an event to the renderer (main.ts's `send`). */ + send: (event: E, data: IpcEvents[E]) => void; +} + +export interface UpdateManager { + /** Poll now. `userInitiated` responses always carry the full status. */ + check(userInitiated?: boolean): Promise; + /** Start the user-confirmed download. */ + download(): Promise<{ ok: boolean; message?: string }>; + /** Apply a ready update: restart into it, or open the downloaded installer. */ + install(): Promise<{ ok: boolean; message?: string }>; } -/** Where a mac user goes to get the new build. */ +/** Where a mac user goes if the in-app download can't find an asset. */ const RELEASES_PAGE = "https://github.com/GitStudioHQ/gitstudio/releases/latest"; const RELEASES_API = "https://api.github.com/repos/GitStudioHQ/gitstudio/releases"; +/** Re-poll cadence while the app stays open. */ +const POLL_MS = 4 * 60 * 60 * 1000; +/** Delay the startup check so it never competes with first paint / repo load. */ +const FIRST_CHECK_DELAY_MS = 20_000; /** Compare dotted numeric versions. > 0 when `a` is newer than `b`. */ export function compareVersions(a: string, b: string): number { @@ -37,101 +61,308 @@ export function compareVersions(a: string, b: string): number { return 0; } +interface RawAsset { + name?: string; + browser_download_url?: string; + size?: number; +} +interface RawRelease { + tag_name?: string; + draft?: boolean; + prerelease?: boolean; + assets?: RawAsset[]; +} + /** The newest non-draft desktop release tag in a GitHub releases payload. */ -export function latestDesktopVersion( - releases: Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>, -): string | undefined { +export function latestDesktopVersion(releases: RawRelease[]): string | undefined { + return latestDesktopRelease(releases)?.version; +} + +/** As {@link latestDesktopVersion}, but keeps the release (for its assets). */ +export function latestDesktopRelease( + releases: RawRelease[], +): { version: string; release: RawRelease } | undefined { if (!Array.isArray(releases)) { return undefined; } // The repo also tags the VS Code extension (`ext-v*`) — only `app-v*` is us. - const tags = releases + const desktop = releases .filter((r) => !r.draft && !r.prerelease && typeof r.tag_name === "string") - .map((r) => r.tag_name as string) - .filter((t) => t.startsWith("app-v")) - .map((t) => t.replace(/^app-v/, "")); - if (tags.length === 0) { + .filter((r) => (r.tag_name as string).startsWith("app-v")) + .map((r) => ({ version: (r.tag_name as string).replace(/^app-v/, ""), release: r })); + if (desktop.length === 0) { return undefined; } - return tags.reduce((best, t) => (compareVersions(t, best) > 0 ? t : best)); + return desktop.reduce((best, r) => (compareVersions(r.version, best.version) > 0 ? r : best)); } -function notify(title: string, body: string, onClick?: () => void): void { - try { - if (!Notification.isSupported()) { - return; - } - const n = new Notification({ title, body }); - if (onClick) { - n.on("click", onClick); - } - n.show(); - } catch { - // A notification must never be the reason the app misbehaves. - } +/** Pick the mac installer asset for this machine from a release's asset list. + * electron-builder names them `GitStudio--.dmg` (zip sibling). */ +export function pickMacAsset( + assets: RawAsset[], + arch: string, +): { name: string; url: string; size: number } | undefined { + const wantArch = arch === "arm64" ? "arm64" : "x64"; + const candidates = (assets ?? []).filter( + (a): a is Required> & RawAsset => + typeof a.name === "string" && typeof a.browser_download_url === "string", + ); + const byExt = (ext: string): (typeof candidates)[number] | undefined => + candidates.find((a) => a.name.endsWith(ext) && a.name.includes(`-${wantArch}`)); + const hit = byExt(".dmg") ?? byExt(".zip"); + return hit ? { name: hit.name, url: hit.browser_download_url, size: hit.size ?? 0 } : undefined; } -async function checkMacUpdate(): Promise { - try { - const res = await fetch(RELEASES_API, { - headers: { Accept: "application/vnd.github+json" }, - signal: AbortSignal.timeout(10_000), - }); - if (!res.ok) { - return; - } - const payload = (await res.json()) as Array<{ - tag_name?: string; - draft?: boolean; - prerelease?: boolean; - }>; - const version = latestDesktopVersion(payload); - if (!version || compareVersions(version, app.getVersion()) <= 0) { - return; - } - notify( - `GitStudio ${version} is available`, - `You're on ${app.getVersion()}. Click to open the download page.`, - () => void shell.openExternal(RELEASES_PAGE), - ); - } catch { - // Offline, rate-limited, or no releases yet — stay quiet. - } -} +export function initAutoUpdate(opts: AutoUpdateOptions): UpdateManager { + const current = app.getVersion(); + const send = opts.send; + + // ── shared state machine ── + let state: "idle" | "available" | "downloading" | "ready" = "idle"; + let availableVersion: string | undefined; + /** Versions already announced this session — background polls stay quiet. */ + const announced = new Set(); + /** mac: the asset chosen at check time; the downloaded installer's path. */ + let macAsset: { name: string; url: string; size: number } | undefined; + let readyPath: string | undefined; -export function initAutoUpdate(opts: AutoUpdateOptions): void { + const disabled: UpdateCheckResult = { + status: "disabled", + current, + message: "Updates are disabled in development builds.", + }; if (opts.isDev) { - return; + return { + check: async () => disabled, + download: async () => ({ ok: false, message: disabled.message }), + install: async () => ({ ok: false, message: disabled.message }), + }; } - if (process.platform === "darwin") { - void checkMacUpdate(); - return; - } + const announce = (version: string, userInitiated: boolean): void => { + if (!userInitiated && announced.has(version)) { + return; + } + announced.add(version); + send("update:available", { version, current }); + }; - // Imported lazily so a missing electron-updater (e.g. a `--dir` smoke build - // that skips optional deps) never crashes startup. - void import("electron-updater") - .then(({ autoUpdater }) => { - // Download in the background and install on quit (autoInstallOnAppQuit - // defaults to true). - autoUpdater.autoDownload = true; - autoUpdater.on("error", () => { - // A repo with no published releases yields a 404 here, which is - // expected until the first `app-v*` tag ships installers. + const sendProgress = (() => { + let lastPct = -1; + return (percent: number): void => { + const p = Math.max(0, Math.min(100, Math.floor(percent))); + if (p !== lastPct) { + lastPct = p; + send("update:progress", { percent: p }); + } + }; + })(); + + // ── macOS: GitHub poll + installer download ── + const macCheck = async (userInitiated: boolean): Promise => { + if (state === "downloading") return { status: "downloading", current, version: availableVersion }; + if (state === "ready") return { status: "ready", current, version: availableVersion }; + try { + const res = await fetch(RELEASES_API, { + headers: { Accept: "application/vnd.github+json" }, + signal: AbortSignal.timeout(10_000), }); - autoUpdater.on("update-downloaded", (info: { version?: string }) => { - const v = info?.version ? ` ${info.version}` : ""; - notify( - `GitStudio${v} is ready to install`, - "It will be applied the next time you quit GitStudio.", - ); + if (!res.ok) { + return { status: "error", current, message: `GitHub responded ${res.status}.` }; + } + const latest = latestDesktopRelease((await res.json()) as RawRelease[]); + if (!latest || compareVersions(latest.version, current) <= 0) { + return { status: "uptodate", current }; + } + state = "available"; + availableVersion = latest.version; + macAsset = pickMacAsset(latest.release.assets ?? [], process.arch); + announce(latest.version, userInitiated); + return { status: "available", current, version: latest.version }; + } catch (e) { + return { + status: "error", + current, + message: e instanceof Error ? e.message : "The update check failed.", + }; + } + }; + + const macDownload = async (): Promise<{ ok: boolean; message?: string }> => { + if (state === "ready" && readyPath && availableVersion) { + send("update:ready", { version: availableVersion, kind: "installer", path: readyPath }); + return { ok: true }; + } + if (state !== "available" || !availableVersion) { + return { ok: false, message: "No update is waiting to download." }; + } + if (!macAsset) { + // The release exists but has no matching mac asset (e.g. a partial + // upload) — send the user to the release page rather than dead-ending. + void shell.openExternal(RELEASES_PAGE); + return { ok: false, message: "Couldn't find a macOS download — opened the releases page." }; + } + state = "downloading"; + const dest = join(app.getPath("downloads"), macAsset.name); + const tmp = `${dest}.part`; + try { + const res = await fetch(macAsset.url, { + redirect: "follow", + signal: AbortSignal.timeout(15 * 60_000), }); - autoUpdater.checkForUpdates().catch(() => { - // No release feed yet — stay silent. + if (!res.ok || !res.body) { + throw new Error(`Download failed (${res.status}).`); + } + const total = Number(res.headers.get("content-length")) || macAsset.size || 0; + const file = createWriteStream(tmp); + const reader = res.body.getReader(); + let got = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + got += value.byteLength; + if (total > 0) sendProgress((got / total) * 100); + if (!file.write(Buffer.from(value))) { + await new Promise((r) => file.once("drain", () => r())); + } + } + await new Promise((resolve, reject) => + file.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + await rename(tmp, dest); + sendProgress(100); + state = "ready"; + readyPath = dest; + send("update:ready", { version: availableVersion, kind: "installer", path: dest }); + return { ok: true }; + } catch (e) { + state = "available"; + void unlink(tmp).catch(() => {}); + return { ok: false, message: e instanceof Error ? e.message : "The download failed." }; + } + }; + + const macInstall = async (): Promise<{ ok: boolean; message?: string }> => { + if (state !== "ready" || !readyPath) { + return { ok: false, message: "No downloaded update to open." }; + } + const err = await shell.openPath(readyPath); + if (err) { + shell.showItemInFolder(readyPath); + return { ok: false, message: err }; + } + return { ok: true }; + }; + + // ── Windows / Linux: electron-updater, gated on confirmation ── + type Updater = typeof import("electron-updater").autoUpdater; + let updater: Updater | undefined; + let updaterWired = false; + const getUpdater = async (): Promise => { + if (updater) return updater; + try { + // Imported lazily so a missing electron-updater (e.g. a `--dir` smoke + // build that skips optional deps) never crashes startup. + updater = (await import("electron-updater")).autoUpdater; + } catch { + return undefined; + } + if (!updaterWired) { + updaterWired = true; + // The whole point: nothing downloads until the user says yes. + updater.autoDownload = false; + updater.autoInstallOnAppQuit = true; + updater.on("error", () => { + // A repo with no published installers yields a 404 here — expected + // until the first app-v* release; surfaced via check() results instead. + }); + updater.on("download-progress", (p: { percent: number }) => sendProgress(p.percent)); + updater.on("update-downloaded", (info: { version?: string }) => { + state = "ready"; + const v = info?.version ?? availableVersion ?? ""; + send("update:ready", { version: v, kind: "restart" }); }); - }) - .catch(() => { - // electron-updater not installed in this build; updates are disabled. - }); + } + return updater; + }; + + const elCheck = async (userInitiated: boolean): Promise => { + if (state === "downloading") return { status: "downloading", current, version: availableVersion }; + if (state === "ready") return { status: "ready", current, version: availableVersion }; + const u = await getUpdater(); + if (!u) { + return { status: "disabled", current, message: "Updates aren't available in this build." }; + } + try { + const r = await u.checkForUpdates(); + const version = r?.updateInfo?.version; + if (version && compareVersions(version, current) > 0) { + state = "available"; + availableVersion = version; + announce(version, userInitiated); + return { status: "available", current, version }; + } + return { status: "uptodate", current }; + } catch (e) { + return { + status: "error", + current, + message: e instanceof Error ? e.message : "The update check failed.", + }; + } + }; + + const elDownload = async (): Promise<{ ok: boolean; message?: string }> => { + if (state === "ready" && availableVersion) { + send("update:ready", { version: availableVersion, kind: "restart" }); + return { ok: true }; + } + if (state !== "available") { + return { ok: false, message: "No update is waiting to download." }; + } + const u = await getUpdater(); + if (!u) { + return { ok: false, message: "Updates aren't available in this build." }; + } + state = "downloading"; + try { + await u.downloadUpdate(); + // update-downloaded flips state to ready + notifies. + return { ok: true }; + } catch (e) { + state = "available"; + return { ok: false, message: e instanceof Error ? e.message : "The download failed." }; + } + }; + + const elInstall = async (): Promise<{ ok: boolean; message?: string }> => { + if (state !== "ready") { + return { ok: false, message: "No downloaded update to install." }; + } + const u = await getUpdater(); + if (!u) { + return { ok: false, message: "Updates aren't available in this build." }; + } + // Restart straight into the new version. + setImmediate(() => u.quitAndInstall()); + return { ok: true }; + }; + + const isMac = process.platform === "darwin"; + const manager: UpdateManager = { + check: (userInitiated = false) => (isMac ? macCheck(userInitiated) : elCheck(userInitiated)), + download: () => (isMac ? macDownload() : elDownload()), + install: () => (isMac ? macInstall() : elInstall()), + }; + + // Poll: shortly after startup (never competing with first paint), then on an + // interval for as long as the app stays open. Background polls announce a + // version once; the rest is the user's call. + setTimeout(() => void manager.check(false), FIRST_CHECK_DELAY_MS); + const timer = setInterval(() => { + if (state === "idle" || state === "available") void manager.check(false); + }, POLL_MS); + timer.unref?.(); + + return manager; } diff --git a/apps/desktop/src/main/cloneBridge.ts b/apps/desktop/src/main/cloneBridge.ts index af5088c..de90fd4 100644 --- a/apps/desktop/src/main/cloneBridge.ts +++ b/apps/desktop/src/main/cloneBridge.ts @@ -11,6 +11,10 @@ import { dialog } from "electron"; import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { validateTargetName } from "../shared/cloneName"; + +export { validateTargetName }; import { join } from "node:path"; import type { CloneProgress, CloneRequest, CloneResult, GhRepoBrief } from "../shared/ipc"; import type { GitHubClient } from "./githubClient"; @@ -28,17 +32,19 @@ export function killActiveClones(): void { activeClones.clear(); } -/** Native "choose a folder" dialog; returns the absolute path or undefined. */ -export async function pickCloneDir(): Promise { +/** Native "choose a folder" dialog; returns the absolute path or undefined. + * `defaultPath` (the configured clone folder) is where it opens. */ +export async function pickCloneDir(defaultPath?: string): Promise { const r = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"], title: "Choose a folder to clone into", + ...(defaultPath ? { defaultPath } : {}), }); return r.canceled || !r.filePaths[0] ? undefined : r.filePaths[0]; } /** Derive the target folder name from an explicit override or the URL's last segment. */ -function targetName(req: CloneRequest): string { +export function targetName(req: CloneRequest): string { const explicit = req.name?.trim(); if (explicit) return explicit; // Strip a trailing slash, then a trailing ".git", and take the last path segment. @@ -69,11 +75,24 @@ export async function startClone( } const name = targetName(req); if (!name) { - return { ok: false, message: "Couldn't derive a folder name from the URL." }; + return { ok: false, code: "bad-name", message: "Couldn't derive a folder name from the URL." }; + } + const nameProblem = validateTargetName(name); + if (nameProblem) { + return { ok: false, code: "bad-name", message: nameProblem }; } // A target dir starting with "-" would be read by git as an option, not a path. if (name.startsWith("-")) { - return { ok: false, message: "Couldn't derive a safe folder name from the URL." }; + return { ok: false, code: "bad-name", message: "Couldn't derive a safe folder name from the URL." }; + } + // Pre-check the destination so a collision is a clean, coded failure instead + // of git's stderr (which the UI used to have to string-match). + if (existsSync(join(req.parentDir, name))) { + return { + ok: false, + code: "dest-exists", + message: `${join(req.parentDir, name)} already exists — pick another folder name or destination.`, + }; } return new Promise((resolve) => { diff --git a/apps/desktop/src/main/ghRepoOpen.ts b/apps/desktop/src/main/ghRepoOpen.ts new file mode 100644 index 0000000..e8a6c08 --- /dev/null +++ b/apps/desktop/src/main/ghRepoOpen.ts @@ -0,0 +1,118 @@ +// One-click "open this GitHub repo as a NORMAL repo" — the whole point of a +// native GitHub app. No destination dialogs, no manual clone step: +// +// 1. If a local clone already exists (any recent repo, or the managed +// folder) whose remote matches, open it INSTANTLY. +// 2. Otherwise clone it into the managed folder (~/GitStudio) with streamed +// progress, then open it. +// +// Either way the app flips to the full repo experience — Code, Commits, +// Branches, PRs — exactly as if the user had opened a local folder. + +import { app } from "electron"; +import { execFile } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { CloneProgress } from "../shared/ipc"; +import { startClone } from "./cloneBridge"; +import { parseGitHubRemote } from "./githubRemote"; +import type { RepoStore } from "./repoStore"; + +/** Where implicit clones live. Fixed and predictable (GitHub Desktop keeps + * ~/Documents/GitHub); users who care about placement use Clone… instead. */ +export function managedReposDir(): string { + return join(app.getPath("home"), "GitStudio"); +} + +/** `git -C root remote get-url origin`, parsed — or undefined. */ +function originOf(root: string): Promise<{ owner: string; repo: string } | undefined> { + return new Promise((resolve) => { + execFile( + "git", + ["-C", root, "remote", "get-url", "origin"], + { timeout: 5_000 }, + (err, stdout) => resolve(err ? undefined : parseGitHubRemote(stdout.trim())), + ); + }); +} + +async function matches(root: string, fullName: string): Promise { + const o = await originOf(root); + return !!o && `${o.owner}/${o.repo}`.toLowerCase() === fullName.toLowerCase(); +} + +export interface GhOpenResult { + ok: boolean; + root?: string; + /** True when this open had to clone first (the renderer words its toast). */ + cloned?: boolean; + message?: string; + /** Machine-readable failure mode — the renderer branches on THIS, never on + * message text (the old /already exists/i match was a fragile seam). */ + code?: "collision" | "clone-failed" | "open-failed" | "bad-name"; +} + +export async function openGitHubRepo( + fullName: string, + repos: RepoStore, + onProgress: (p: CloneProgress) => void, + /** Injectable for tests (app.getPath needs a live Electron app). */ + managed: string = managedReposDir(), + /** Per-action destination override (the "Choose location…" sheet). */ + dest?: string, + /** Per-action folder-name override. */ + nameOverride?: string, +): Promise { + const [owner, repo] = fullName.split("/", 2); + if (!owner || !repo) { + return { ok: false, code: "bad-name", message: "That doesn't look like an owner/repo name." }; + } + + // 1. An existing clone wins — recents first (where the user actually works), + // then the managed folder's two naming schemes. + const candidates = [ + ...repos.recentRepos().map((r) => r.root), + join(managed, repo), + join(managed, `${owner}-${repo}`), + ]; + // Probe candidates in PARALLEL (each probe is a git subprocess; a long + // recents list on a slow volume serially stalled the "Preparing…" card), + // then honor the original preference order when picking. + const unique = [...new Set(candidates)].filter((root) => existsSync(root)); + const results = await Promise.all(unique.map((root) => matches(root, fullName))); + const hitRoot = unique.find((_, i) => results[i]); + if (hitRoot) { + const info = await repos.open(hitRoot); + return info + ? { ok: true, root: hitRoot, cloned: false } + : { ok: false, code: "open-failed", message: `Found a clone at ${hitRoot}, but it couldn't be opened.` }; + } + + // 2. No clone anywhere — make one in the chosen destination (an explicit + // override wins; otherwise the configured default folder). + const parent = dest ?? managed; + await mkdir(parent, { recursive: true }); + // Prefer the explicit name, then the plain repo name; fall back to + // owner-repo when a DIFFERENT project already took that folder. + let name = nameOverride?.trim() || repo; + if (!nameOverride && existsSync(join(parent, name))) name = `${owner}-${repo}`; + if (existsSync(join(parent, name))) { + return { + ok: false, + code: "collision", + message: `${join(parent, name)} already exists but isn't this repository — pick another destination or folder name.`, + }; + } + const result = await startClone( + { url: `https://github.com/${fullName}.git`, parentDir: parent, name }, + onProgress, + ); + if (!result.ok || !result.root) { + return { ok: false, code: "clone-failed", message: result.message || "The clone failed." }; + } + const info = await repos.open(result.root); + return info + ? { ok: true, root: result.root, cloned: true } + : { ok: false, code: "open-failed", message: `Cloned to ${result.root}, but it couldn't be opened.` }; +} diff --git a/apps/desktop/src/main/gitBridge.ts b/apps/desktop/src/main/gitBridge.ts index 6000a7a..89312f5 100644 --- a/apps/desktop/src/main/gitBridge.ts +++ b/apps/desktop/src/main/gitBridge.ts @@ -1035,12 +1035,26 @@ export class GitBridge { if ((name && name.startsWith("-")) || (email && email.startsWith("-"))) { return { ok: false, changed: false, message: "Name and email can't start with “-”." }; } + if (!name && !email) { + return { ok: false, changed: false, message: "Enter a name or an email to save." }; + } try { - if (name) { - await ctx.process.run(["config", "--global", "user.name", name]); - } - if (email) { - await ctx.process.run(["config", "--global", "user.email", email]); + const writes: Array<[string, string]> = []; + if (name) writes.push(["user.name", name]); + if (email) writes.push(["user.email", email]); + for (const [key, value] of writes) { + const r = await ctx.process.run(["config", "--global", key, value]); + // `git config` exits non-zero WITHOUT throwing (run() resolves with the + // code) — e.g. a read-only or locked ~/.gitconfig, or a broken include. + // This used to fall through to "updated ✓" while writing nothing. + if (r.code !== 0) { + return { + ok: false, + changed: false, + message: + r.stderr.trim() || `git config --global ${key} failed (exit ${r.code}).`, + }; + } } return { ok: true, changed: true }; } catch (err) { @@ -1224,6 +1238,32 @@ export class GitBridge { return branches; } + /** Recent commits reachable from one ref — the browsable history a peek card + * shows for a branch/remote/tag without loading the whole graph. */ + async refLog(req: { ref: string; maxCount?: number }): Promise { + const ctx = this.ctx(); + if (!ctx || !safeArg(req.ref)) { + return []; + } + const max = Math.min(Math.max(req.maxCount ?? 25, 1), 100); + const out: CompareCommit[] = []; + try { + for await (const c of ctx.log.streamCommits({ revRange: req.ref, maxCount: max })) { + out.push({ + sha: c.sha, + shortSha: c.sha.slice(0, 7), + subject: c.subject, + author: c.author, + date: c.authorDate, + }); + } + } catch { + // An unknown/unborn ref is a state, not an error — the peek shows empty. + return []; + } + return out; + } + async branchCreate(req: { name: string; checkout?: boolean }): Promise { if (!safeArg(req.name)) return UNSAFE_REF_RESULT; return this.staged((ctx) => diff --git a/apps/desktop/src/main/github/actions.ts b/apps/desktop/src/main/github/actions.ts index 57fed50..cb65dae 100644 --- a/apps/desktop/src/main/github/actions.ts +++ b/apps/desktop/src/main/github/actions.ts @@ -17,9 +17,13 @@ import { access, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { GitHubClient, enc, type TokenGetter } from "../githubClient"; +import { mapJob, mapRun, type RawJob, type RawRun } from "./maps"; +import { sliceLogDelta, type LogDelta } from "./logTail"; +import { PAGE_CAPS } from "../githubPaging"; import { ExpectedError } from "../expectedError"; import { errorFields, githubHttpError, networkError } from "../githubErrors"; import type { + ActionsRunsFilter, ArtifactInfo, CommitActionResult, RepoSecretInfo, @@ -35,33 +39,6 @@ const API_BASE = "https://api.github.com"; // ── Raw GitHub REST shapes (only the fields we map) ────────────────────────── -interface RawRun { - id: number; - name?: string; - display_title?: string; - status?: string; - conclusion?: string; - head_branch?: string; - event?: string; - created_at?: string; - html_url?: string; -} -interface RawStep { - name?: string; - status?: string; - conclusion?: string; - number?: number; -} -interface RawJob { - id: number; - name?: string; - status?: string; - conclusion?: string; - html_url?: string; - started_at?: string; - completed_at?: string; - steps?: RawStep[]; -} interface RawWorkflow { id: number; name?: string; @@ -88,35 +65,6 @@ interface RawVariable { // ── Mappers (Raw* → public ipc types) ──────────────────────────────────────── -function mapRun(r: RawRun): WorkflowRun { - return { - id: r.id, - name: r.name ?? r.display_title ?? "(run)", - status: r.status ?? "", - conclusion: r.conclusion ?? "", - branch: r.head_branch ?? "", - event: r.event ?? "", - createdAt: r.created_at ?? "", - htmlUrl: r.html_url ?? "", - }; -} -function mapJob(j: RawJob): WorkflowJob { - return { - id: j.id, - name: j.name ?? "(job)", - status: j.status ?? "", - conclusion: j.conclusion ?? "", - htmlUrl: j.html_url ?? "", - startedAt: j.started_at ?? "", - completedAt: j.completed_at ?? "", - steps: (j.steps ?? []).map((s) => ({ - name: s.name ?? "", - status: s.status ?? "", - conclusion: s.conclusion ?? "", - number: s.number ?? 0, - })), - }; -} function mapWorkflow(w: RawWorkflow): WorkflowInfo { return { id: w.id, @@ -178,10 +126,20 @@ function ghHeaders(token: string): Record { * (the signed URL needs none, and GitHub rejects a forwarded Bearer). A direct 2xx * is returned as-is. Throws a clean Error on any non-OK status. */ -async function fetchSignedRedirect(token: string, path: string): Promise { +async function fetchSignedRedirect( + token: string, + path: string, + timeoutMs = 30_000, +): Promise { let res: Response; try { - res = await fetch(`${API_BASE}${path}`, { headers: ghHeaders(token), redirect: "manual" }); + res = await fetch(`${API_BASE}${path}`, { + headers: ghHeaders(token), + redirect: "manual", + // This used to be the app's ONLY un-timed network path — a hung blob + // fetch pinned a log pane on its spinner forever. + signal: AbortSignal.timeout(timeoutMs), + }); } catch { throw networkError(); } @@ -190,7 +148,7 @@ async function fetchSignedRedirect(token: string, path: string): Promise { - const raw = await client.request<{ workflow_runs?: RawRun[] }>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/actions/runs?per_page=30`, +/** Recent workflow runs for the repo (paged, newest first). `filter` narrows + * SERVER-SIDE: workflowId switches to the per-workflow endpoint; branch / + * actor / event / status ride as query params GitHub filters itself. */ +export async function listRuns( + client: GitHubClient, + owner: string, + repo: string, + filter?: ActionsRunsFilter, +): Promise { + const base = filter?.workflowId + ? `/repos/${enc(owner)}/${enc(repo)}/actions/workflows/${filter.workflowId}/runs` + : `/repos/${enc(owner)}/${enc(repo)}/actions/runs`; + const qs = new URLSearchParams({ per_page: "100" }); + if (filter?.branch) qs.set("branch", filter.branch); + if (filter?.actor) qs.set("actor", filter.actor); + if (filter?.event) qs.set("event", filter.event); + if (filter?.status) qs.set("status", filter.status); + const raw = await client.requestPagedKey( + `${base}?${qs.toString()}`, + "workflow_runs", + PAGE_CAPS.runs, ); - return (raw.workflow_runs ?? []).map(mapRun); + return raw.map(mapRun); } /** A single run plus its jobs (GET /actions/runs/{id} + /jobs), for the detail pane. */ @@ -223,11 +197,12 @@ export async function getRunDetail( "GET", `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}`, ); - const jobsRaw = await client.request<{ jobs?: RawJob[] }>( - "GET", + const jobsRaw = await client.requestPagedKey( `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/jobs?per_page=100`, + "jobs", + PAGE_CAPS.detail, ); - return { run: mapRun(run), jobs: (jobsRaw.jobs ?? []).map(mapJob) }; + return { run: mapRun(run), jobs: jobsRaw.map(mapJob) }; } /** All workflows declared in this repo (GET /actions/workflows). */ @@ -291,36 +266,36 @@ export async function jobLog( } /** - * Plain-text logs for a WHOLE run, assembled from its jobs. The native - * `…/runs/{id}/logs` endpoint returns a ZIP (heavy + needs unzip in-process); - * instead we fetch the run's jobs and concatenate each job's `jobLog` under a - * `=== job name ===` banner — the same text, streamable straight into the viewer. - * One job's failure is annotated inline rather than failing the whole aggregate. + * Incremental log delta for ONE job — the live-tail workhorse. GitHub's log + * endpoint has no offset support, so we re-fetch the full text and ship only + * what the renderer hasn't seen (see logTail.sliceLogDelta for the reset / + * truncation semantics). */ -export async function runLog( +export async function jobLogChunk( client: GitHubClient, owner: string, repo: string, - req: { runId: number }, -): Promise { - const jobsRaw = await client.request<{ jobs?: RawJob[] }>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${req.runId}/jobs?per_page=100`, - ); - const jobs = jobsRaw.jobs ?? []; - if (jobs.length === 0) return "This run reported no jobs."; - const parts: string[] = []; - for (const j of jobs) { - const name = j.name ?? `job ${j.id}`; - parts.push(`=== ${name} ===`); - try { - parts.push((await jobLog(client, owner, repo, { jobId: j.id })).trimEnd()); - } catch (err) { - parts.push(`[logs unavailable: ${err instanceof Error ? err.message : String(err)}]`); - } - parts.push(""); // blank line between jobs + req: { jobId: number; offset: number }, +): Promise { + const full = await jobLog(client, owner, repo, { jobId: req.jobId }); + return sliceLogDelta(full, Math.max(0, req.offset)); +} + +/** Save one job's full log to ~/Downloads as a .log file. Mutation-shaped. */ +export async function saveLog( + client: GitHubClient, + owner: string, + repo: string, + req: { jobId: number; name: string }, +): Promise { + try { + const text = await jobLog(client, owner, repo, { jobId: req.jobId }); + const dest = await uniqueDownloadPath(safeFileName(req.name || `job-${req.jobId}`) + ".log"); + await writeFile(dest, text, "utf8"); + return { ok: true, changed: false, message: `Saved to ${dest}` }; + } catch (err) { + return { ok: false, changed: false, ...errorFields(err) }; } - return parts.join("\n"); } /** Artifacts produced by a run (GET /actions/runs/{id}/artifacts). */ diff --git a/apps/desktop/src/main/github/gists.ts b/apps/desktop/src/main/github/gists.ts index c1f89b4..0d2daa0 100644 --- a/apps/desktop/src/main/github/gists.ts +++ b/apps/desktop/src/main/github/gists.ts @@ -11,6 +11,7 @@ // can select it after a refresh. import { GitHubClient, enc, mapUser, RawUser } from "../githubClient"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; import type { CommitActionResult, @@ -82,7 +83,7 @@ function mapGist(g: RawGist): GistInfo { * The list payload carries file METADATA only — file `content` is null here, so * the detail view re-fetches the full gist via `getGist`. */ export async function listGists(client: GitHubClient): Promise { - const raw = await client.request("GET", "/gists?per_page=100"); + const raw = await client.requestPaged("/gists?per_page=100", PAGE_CAPS.account); return raw.map(mapGist); } diff --git a/apps/desktop/src/main/github/issues.ts b/apps/desktop/src/main/github/issues.ts index bd1d4e3..7cb2fab 100644 --- a/apps/desktop/src/main/github/issues.ts +++ b/apps/desktop/src/main/github/issues.ts @@ -13,7 +13,16 @@ // Everything here is REST (Issues live under the OAuth `repo` scope, same as // PRs); GraphQL is only needed for Projects v2, which lives elsewhere. -import { GitHubClient, enc, mapUser, type RawUser } from "../githubClient"; +import { GitHubClient, enc } from "../githubClient"; +import { + mapComment, + mapIssue, + mapUser, + type RawIssue, + type RawIssueComment, + type RawUser, +} from "./maps"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; import type { CommitActionResult, @@ -27,30 +36,6 @@ import type { // ── Raw GitHub payloads (only what we read) ────────────────────────────────── -interface RawLabelRef { - name: string; - color: string; -} -interface RawIssue { - number: number; - title: string; - body: string | null; - state: string; - html_url: string; - user: RawUser | null; - created_at: string; - updated_at: string; - comments: number; - labels?: (RawLabelRef | string)[]; - assignees?: RawUser[]; - pull_request?: unknown; -} -interface RawIssueComment { - id: number; - user?: RawUser | null; - body?: string | null; - created_at: string; -} interface RawRepoLabel { name: string; color: string; @@ -67,34 +52,7 @@ interface RawMilestone { // ── Mappers ────────────────────────────────────────────────────────────────── -function mapIssue(i: RawIssue): IssueInfo { - return { - number: i.number, - title: i.title, - body: i.body, - state: i.state, - htmlUrl: i.html_url, - user: mapUser(i.user), - createdAt: i.created_at, - updatedAt: i.updated_at, - comments: i.comments, - labels: (i.labels ?? []).map((l) => - typeof l === "string" ? { name: l, color: "888888" } : { name: l.name, color: l.color }, - ), - assignees: (i.assignees ?? []) - .map(mapUser) - .filter((u): u is GitHubUser => u !== null), - }; -} -function mapComment(c: RawIssueComment): IssueComment { - return { - id: c.id, - author: mapUser(c.user ?? null), - body: c.body ?? "", - createdAt: c.created_at, - }; -} function mapLabel(l: RawRepoLabel): RepoLabel { return { name: l.name, color: l.color, description: l.description ?? null }; @@ -123,9 +81,9 @@ export async function listIssues( repo: string, state: "open" | "closed" | "all" = "open", ): Promise { - const raw = await client.request( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/issues?state=${state}&sort=updated&direction=desc&per_page=50`, + const raw = await client.requestPaged( + `/repos/${enc(owner)}/${enc(repo)}/issues?state=${state}&sort=updated&direction=desc&per_page=100`, + PAGE_CAPS.list, ); return raw.filter((i) => !i.pull_request).map(mapIssue); } @@ -145,9 +103,9 @@ export async function getIssueDetail( await client.request("GET", `/repos/${enc(owner)}/${enc(repo)}/issues/${n}`), ); const comments = await client - .request( - "GET", + .requestPaged( `/repos/${enc(owner)}/${enc(repo)}/issues/${n}/comments?per_page=100`, + PAGE_CAPS.detail, ) .then((raw) => raw.map(mapComment)) .catch(() => [] as IssueComment[]); @@ -160,9 +118,9 @@ export async function listLabels( owner: string, repo: string, ): Promise { - const raw = await client.request( - "GET", + const raw = await client.requestPaged( `/repos/${enc(owner)}/${enc(repo)}/labels?per_page=100`, + PAGE_CAPS.detail, ); return raw.map(mapLabel); } diff --git a/apps/desktop/src/main/github/logTail.ts b/apps/desktop/src/main/github/logTail.ts new file mode 100644 index 0000000..f0ea420 --- /dev/null +++ b/apps/desktop/src/main/github/logTail.ts @@ -0,0 +1,47 @@ +// Pure incremental-log helpers for the live-tail pipeline — no client or +// Electron imports, unit-tested in isolation. +// +// GitHub's job-log endpoint has no byte-range/offset support: a live tail +// re-fetches the FULL text each poll. `sliceLogDelta` turns that full text +// plus the renderer's last-seen offset into the smallest correct delta to +// ship over IPC — an append in the common case, an explicit reset when the +// log shrank (a re-run attempt replaced it), and a truncated tail window when +// the log outgrew the cap. + +import type { LogDelta } from "../../shared/ipc"; + +export type { LogDelta }; + +/** Hard ceiling on how much log text the renderer holds per job. */ +export const MAX_LOG_BYTES = 8 * 1024 * 1024; + +/** + * Compute the delta between the freshly fetched `full` text and the + * renderer's `offset` (how many chars it already has). `cap` bounds the + * window (defaults to {@link MAX_LOG_BYTES}). + */ +export function sliceLogDelta(full: string, offset: number, cap = MAX_LOG_BYTES): LogDelta { + const total = full.length; + // The log shrank — a re-run attempt replaced it. Start over. + if (offset > total) { + return trimmed(full, total, cap); + } + // The unseen remainder alone exceeds the cap — the append would blow the + // renderer's budget; hand back a fresh tail window instead. + if (total - offset > cap) { + return trimmed(full, total, cap); + } + return { text: full.slice(offset), totalLength: total, reset: false, truncated: false }; +} + +function trimmed(full: string, total: number, cap: number): LogDelta { + if (total <= cap) { + return { text: full, totalLength: total, reset: true, truncated: false }; + } + // Cut at a line boundary inside the tail window so the first rendered line + // isn't a torn fragment. + let start = total - cap; + const nl = full.indexOf("\n", start); + if (nl !== -1 && nl < total - 1) start = nl + 1; + return { text: full.slice(start), totalLength: total, reset: true, truncated: true }; +} diff --git a/apps/desktop/src/main/github/maps.ts b/apps/desktop/src/main/github/maps.ts new file mode 100644 index 0000000..3e1946b --- /dev/null +++ b/apps/desktop/src/main/github/maps.ts @@ -0,0 +1,414 @@ +// The ONE home for GitHub raw-payload shapes and their wire-type mappers. +// +// Pure module — imports nothing but the shared IPC types (like githubPaging), +// so every mapper unit-tests in isolation with canned API JSON. Before this +// module existed, mapRun/mapPull/mapIssue each lived in TWO divergent copies +// (githubClient.ts vs github/*.ts) and quietly disagreed about which fields +// survive; new fields now land HERE, once. +// +// Mapping convention: raw fields are optional (GitHub omits more than its +// docs admit), mapped fields are concrete — absent strings become "", absent +// arrays [], absent users null. The UI never branches on undefined. + +import type { + GitHubUser, + IssueComment, + IssueInfo, + NotificationThread, + PullRequest, + ReactionSummary, + WorkflowJob, + WorkflowRun, + WorkflowStep, +} from "../../shared/ipc"; + +// ── Users ──────────────────────────────────────────────────────────────────── + +export interface RawUser { + login: string; + avatar_url?: string; +} + +export function mapUser(u: RawUser | null | undefined): GitHubUser | null { + return u ? { login: u.login, avatarUrl: u.avatar_url ?? null } : null; +} + +// ── Workflow runs ──────────────────────────────────────────────────────────── + +export interface RawRun { + id: number; + run_number?: number; + run_attempt?: number; + /** The WORKFLOW's name ("Desktop CI"). */ + name?: string; + /** The run's own title (commit subject / PR title). */ + display_title?: string; + status?: string; + conclusion?: string; + head_branch?: string; + head_sha?: string; + event?: string; + created_at?: string; + updated_at?: string; + run_started_at?: string; + html_url?: string; + actor?: RawUser | null; + triggering_actor?: RawUser | null; + workflow_id?: number; + /** The workflow file path (".github/workflows/desktop.yml"). */ + path?: string; + head_commit?: { message?: string; author?: { name?: string } | null } | null; + pull_requests?: { number: number }[]; +} + +export function mapRun(r: RawRun): WorkflowRun { + return { + id: r.id, + runNumber: r.run_number ?? 0, + runAttempt: r.run_attempt ?? 1, + name: r.name ?? r.display_title ?? "(run)", + displayTitle: r.display_title ?? r.name ?? "(run)", + status: r.status ?? "", + conclusion: r.conclusion ?? "", + branch: r.head_branch ?? "", + headSha: r.head_sha ?? "", + event: r.event ?? "", + createdAt: r.created_at ?? "", + updatedAt: r.updated_at ?? "", + runStartedAt: r.run_started_at ?? "", + htmlUrl: r.html_url ?? "", + actor: mapUser(r.actor), + triggeringActor: mapUser(r.triggering_actor), + workflowId: r.workflow_id ?? 0, + workflowPath: r.path ?? "", + headCommitMessage: r.head_commit?.message ?? "", + headCommitAuthor: r.head_commit?.author?.name ?? "", + pullRequests: (r.pull_requests ?? []).map((p) => ({ number: p.number })), + }; +} + +// ── Workflow jobs + steps ──────────────────────────────────────────────────── + +export interface RawStep { + name?: string; + status?: string; + conclusion?: string; + number?: number; + started_at?: string | null; + completed_at?: string | null; +} + +export interface RawJob { + id: number; + run_id?: number; + run_attempt?: number; + name?: string; + status?: string; + conclusion?: string; + html_url?: string; + /** Queued time — `started_at − created_at` is the queue latency. */ + created_at?: string; + started_at?: string; + completed_at?: string; + steps?: RawStep[]; + runner_name?: string | null; + runner_group_name?: string | null; + labels?: string[]; + workflow_name?: string | null; + head_branch?: string | null; +} + +export function mapStep(s: RawStep): WorkflowStep { + return { + name: s.name ?? "", + status: s.status ?? "", + conclusion: s.conclusion ?? "", + number: s.number ?? 0, + startedAt: s.started_at ?? "", + completedAt: s.completed_at ?? "", + }; +} + +export function mapJob(j: RawJob): WorkflowJob { + return { + id: j.id, + runId: j.run_id ?? 0, + runAttempt: j.run_attempt ?? 1, + name: j.name ?? "(job)", + status: j.status ?? "", + conclusion: j.conclusion ?? "", + htmlUrl: j.html_url ?? "", + createdAt: j.created_at ?? "", + startedAt: j.started_at ?? "", + completedAt: j.completed_at ?? "", + steps: (j.steps ?? []).map(mapStep), + runnerName: j.runner_name ?? "", + runnerGroupName: j.runner_group_name ?? "", + labels: j.labels ?? [], + workflowName: j.workflow_name ?? "", + headBranch: j.head_branch ?? "", + }; +} + +// ── Reactions ──────────────────────────────────────────────────────────────── + +export interface RawReactions { + total_count?: number; + "+1"?: number; + "-1"?: number; + laugh?: number; + hooray?: number; + confused?: number; + heart?: number; + rocket?: number; + eyes?: number; +} + +/** Undefined when nobody reacted — the UI renders nothing rather than a row of + * zeroes, which is what GitHub does and what reads honestly. */ +export function mapReactions(r: RawReactions | null | undefined): ReactionSummary | undefined { + if (!r) return undefined; + const total = r.total_count ?? 0; + if (total <= 0) return undefined; + return { + total, + plusOne: r["+1"] ?? 0, + minusOne: r["-1"] ?? 0, + laugh: r.laugh ?? 0, + hooray: r.hooray ?? 0, + confused: r.confused ?? 0, + heart: r.heart ?? 0, + rocket: r.rocket ?? 0, + eyes: r.eyes ?? 0, + }; +} + +// ── Pull requests ──────────────────────────────────────────────────────────── + +export interface RawRef { + ref: string; + sha: string; + repo?: { full_name?: string } | null; +} + +export interface RawPull { + number: number; + title: string; + body: string | null; + state: string; + draft?: boolean; + html_url: string; + user: RawUser | null; + created_at: string; + updated_at: string; + head: RawRef; + base: RawRef; + labels?: { name: string; color: string }[]; + comments?: number; + additions?: number; + deletions?: number; + changed_files?: number; + assignees?: RawUser[]; + merged_at?: string | null; + closed_at?: string | null; + merged_by?: RawUser | null; + review_comments?: number; + commits?: number; + requested_reviewers?: RawUser[]; + milestone?: { number: number; title: string } | null; + author_association?: string; + reactions?: RawReactions | null; +} + +/** Users, with the nulls dropped — a list mapper that keeps `null` holes makes + * every caller re-filter. */ +function userList(raw: RawUser[] | undefined): GitHubUser[] { + return (raw ?? []).map(mapUser).filter((u): u is GitHubUser => u !== null); +} + +export function mapPull(p: RawPull): PullRequest { + return { + number: p.number, + title: p.title, + body: p.body, + state: p.state, + draft: p.draft ?? false, + htmlUrl: p.html_url, + user: mapUser(p.user), + createdAt: p.created_at, + updatedAt: p.updated_at, + head: { ref: p.head.ref, sha: p.head.sha }, + base: { ref: p.base.ref, sha: p.base.sha }, + labels: (p.labels ?? []).map((l) => ({ name: l.name, color: l.color })), + comments: p.comments, + additions: p.additions, + deletions: p.deletions, + changedFiles: p.changed_files, + assignees: userList(p.assignees), + mergedAt: p.merged_at ?? null, + closedAt: p.closed_at ?? null, + mergedBy: mapUser(p.merged_by), + reviewComments: p.review_comments, + commits: p.commits, + requestedReviewers: userList(p.requested_reviewers), + milestone: p.milestone ? { number: p.milestone.number, title: p.milestone.title } : null, + authorAssociation: p.author_association, + // Only meaningful when it DIFFERS from base — a same-repo branch is the + // normal case and shouldn't paint a "fork" pill on every row. + headRepoFullName: + p.head.repo?.full_name && p.head.repo.full_name !== p.base.repo?.full_name + ? p.head.repo.full_name + : null, + reactions: mapReactions(p.reactions), + }; +} + +// ── Issues ─────────────────────────────────────────────────────────────────── + +export interface RawLabelRef { + name: string; + color: string; +} + +export interface RawIssue { + number: number; + title: string; + body: string | null; + state: string; + html_url: string; + user: RawUser | null; + created_at: string; + updated_at: string; + comments: number; + labels?: (RawLabelRef | string)[]; + assignees?: RawUser[]; + milestone?: { number: number; title: string } | null; + pull_request?: unknown; + closed_at?: string | null; + closed_by?: RawUser | null; + state_reason?: string | null; + author_association?: string; + reactions?: RawReactions | null; +} + +export function mapIssue(i: RawIssue): IssueInfo { + return { + number: i.number, + title: i.title, + body: i.body, + state: i.state, + htmlUrl: i.html_url, + user: mapUser(i.user), + createdAt: i.created_at, + updatedAt: i.updated_at, + comments: i.comments, + labels: (i.labels ?? []).map((l) => + typeof l === "string" ? { name: l, color: "888888" } : { name: l.name, color: l.color }, + ), + assignees: userList(i.assignees), + milestone: i.milestone ? { number: i.milestone.number, title: i.milestone.title } : null, + closedAt: i.closed_at ?? null, + closedBy: mapUser(i.closed_by), + stateReason: i.state_reason ?? null, + authorAssociation: i.author_association, + reactions: mapReactions(i.reactions), + }; +} + +export interface RawIssueComment { + id: number; + user?: RawUser | null; + body?: string | null; + created_at: string; + updated_at?: string; + author_association?: string; + reactions?: RawReactions | null; +} + +export function mapComment(c: RawIssueComment): IssueComment { + return { + id: c.id, + author: mapUser(c.user ?? null), + body: c.body ?? "", + createdAt: c.created_at, + updatedAt: c.updated_at, + authorAssociation: c.author_association, + reactions: mapReactions(c.reactions), + }; +} + +// ── Notifications ──────────────────────────────────────────────────────────── + +export interface RawNotification { + id: string; + unread: boolean; + reason: string; + updated_at: string; + last_read_at?: string | null; + subject: { title?: string; type?: string; url?: string | null } | null; + repository: { + full_name: string; + html_url: string; + owner?: { avatar_url?: string } | null; + } | null; +} + +/** What a notification is ABOUT, parsed out of the subject's API url. + * + * This is the whole reason Inbox rows can open in-app: the subject carries no + * html_url and no number, but its API url's tail IS the number (or the sha). + * Pure and exported so every form GitHub emits is pinned by tests. */ +export function subjectRef( + type: string | undefined, + apiUrl: string | null | undefined, +): { kind: NotificationThread["subjectKind"]; number?: number; sha?: string } { + const url = apiUrl ?? ""; + const numbered = /\/repos\/[^/]+\/[^/]+\/(pulls|issues|releases)\/(\d+)(?:$|[?#])/.exec(url); + if (numbered) { + const kind = numbered[1] === "pulls" ? "pull" : numbered[1] === "issues" ? "issue" : "release"; + return { kind, number: Number(numbered[2]) }; + } + const commit = /\/repos\/[^/]+\/[^/]+\/commits\/([0-9a-f]{7,40})(?:$|[?#])/i.exec(url); + if (commit) return { kind: "commit", sha: commit[1] }; + // No usable url (Discussions, and Releases addressed by tag) — fall back to + // the declared subject type so the row can still say what it is. + const t = (type ?? "").toLowerCase(); + if (t === "pullrequest") return { kind: "pull" }; + if (t === "issue") return { kind: "issue" }; + if (t === "release") return { kind: "release" }; + if (t === "commit") return { kind: "commit" }; + if (t === "discussion") return { kind: "discussion" }; + return { kind: "other" }; +} + +/** github.com url for a notification subject — numbered issues/PRs/releases + * resolve exactly; anything else falls back to the repository. */ +export function subjectHtmlUrl(n: RawNotification): string { + const repo = n.repository?.full_name ?? ""; + const ref = subjectRef(n.subject?.type, n.subject?.url); + if (repo && ref.number !== undefined) { + const path = ref.kind === "pull" ? "pull" : ref.kind === "issue" ? "issues" : "releases"; + return `https://github.com/${repo}/${path}/${ref.number}`; + } + if (repo && ref.sha) return `https://github.com/${repo}/commit/${ref.sha}`; + return n.repository?.html_url ?? ""; +} + +export function mapNotification(n: RawNotification): NotificationThread { + const ref = subjectRef(n.subject?.type, n.subject?.url); + return { + id: n.id, + title: n.subject?.title ?? "(untitled)", + type: n.subject?.type ?? "", + reason: n.reason ?? "", + repo: n.repository?.full_name ?? "", + repoAvatarUrl: n.repository?.owner?.avatar_url ?? null, + updatedAt: n.updated_at ?? "", + unread: n.unread ?? false, + htmlUrl: subjectHtmlUrl(n), + lastReadAt: n.last_read_at ?? null, + subjectKind: ref.kind, + subjectNumber: ref.number, + subjectSha: ref.sha, + }; +} diff --git a/apps/desktop/src/main/github/myWork.ts b/apps/desktop/src/main/github/myWork.ts new file mode 100644 index 0000000..56526f9 --- /dev/null +++ b/apps/desktop/src/main/github/myWork.ts @@ -0,0 +1,74 @@ +// "My Work" — everything in the CURRENT repo that involves the signed-in user, +// gathered with the search API's `@me` qualifiers (no login round-trip needed): +// • open PRs where my review is requested +// • open issues/PRs assigned to me +// • open PRs I authored +// • open items that mention me (minus my own) +// +// Four searches run in parallel; each is best-effort (one failing bucket never +// blanks the page). Items are deduped across buckets in priority order — +// review-requested beats assigned beats my-prs beats mentions — so one PR shows +// once, under the most actionable heading. + +import { GitHubClient } from "../githubClient"; +import type { MyWorkItem } from "../../shared/ipc"; + +interface RawSearchIssue { + number: number; + title: string; + state: string; + draft?: boolean; + pull_request?: unknown; + updated_at: string; + comments?: number; + user?: { login?: string } | null; +} + +async function search(client: GitHubClient, q: string): Promise { + const res = await client.request<{ items?: RawSearchIssue[] }>( + "GET", + `/search/issues?q=${encodeURIComponent(q)}&sort=updated&per_page=50`, + ); + return res.items ?? []; +} + +export async function myWork( + client: GitHubClient, + owner: string, + repo: string, +): Promise { + const scope = `repo:${owner}/${repo} is:open`; + const [rev, assigned, mine, mentions] = await Promise.all([ + search(client, `${scope} is:pr review-requested:@me`).catch(() => [] as RawSearchIssue[]), + search(client, `${scope} assignee:@me`).catch(() => [] as RawSearchIssue[]), + search(client, `${scope} is:pr author:@me`).catch(() => [] as RawSearchIssue[]), + search(client, `${scope} mentions:@me -author:@me`).catch(() => [] as RawSearchIssue[]), + ]); + + const out: MyWorkItem[] = []; + const seen = new Set(); + const push = (kind: MyWorkItem["kind"], items: RawSearchIssue[]): void => { + for (const it of items) { + const type: MyWorkItem["type"] = it.pull_request ? "pr" : "issue"; + const key = `${type}#${it.number}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + kind, + type, + number: it.number, + title: it.title, + state: it.state, + draft: !!it.draft, + updatedAt: it.updated_at, + comments: it.comments ?? 0, + author: it.user?.login ?? null, + }); + } + }; + push("review-requested", rev); + push("assigned", assigned); + push("my-prs", mine); + push("mentions", mentions); + return out; +} diff --git a/apps/desktop/src/main/github/notifications.ts b/apps/desktop/src/main/github/notifications.ts index b933ce0..431d046 100644 --- a/apps/desktop/src/main/github/notifications.ts +++ b/apps/desktop/src/main/github/notifications.ts @@ -10,7 +10,9 @@ // scope is needed; the existing `request`/`requestBody` primitives set Bearer. import { GitHubClient, enc } from "../githubClient"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; +import { mapNotification, type RawNotification } from "./maps"; import type { NotificationActionResult, NotificationThread } from "../../shared/ipc"; /** Options for the inbox listing (mirrors the IPC request shape). */ @@ -34,8 +36,11 @@ export async function listNotifications( const qs = new URLSearchParams(); if (opts.all) qs.set("all", "true"); if (opts.participating) qs.set("participating", "true"); - qs.set("per_page", "50"); - const raw = await client.request("GET", `/notifications?${qs.toString()}`); + qs.set("per_page", "50"); // the notifications endpoint caps per_page at 50 + const raw = await client.requestPaged( + `/notifications?${qs.toString()}`, + PAGE_CAPS.notifications, + ); return raw.map(mapNotification); } @@ -75,57 +80,5 @@ export async function markAllNotificationsRead( // ── Raw API shapes + mappers ───────────────────────────────────────────────── -interface RawNotificationOwner { - avatar_url?: string | null; -} -interface RawNotificationSubject { - title: string; - type: string; - url: string | null; - latest_comment_url: string | null; -} -interface RawNotificationRepository { - full_name: string; - html_url: string; - owner?: RawNotificationOwner | null; -} -interface RawNotification { - id: string; - unread: boolean; - reason: string; - updated_at: string; - subject: RawNotificationSubject; - repository: RawNotificationRepository; -} -function mapNotification(n: RawNotification): NotificationThread { - return { - id: n.id, - title: n.subject?.title ?? "(untitled)", - type: n.subject?.type ?? "", - reason: n.reason ?? "", - repo: n.repository?.full_name ?? "", - repoAvatarUrl: n.repository?.owner?.avatar_url ?? null, - updatedAt: n.updated_at ?? "", - unread: n.unread ?? false, - htmlUrl: subjectHtmlUrl(n), - }; -} -/** - * GitHub's notification subject `url` is an API url - * (api.github.com/repos/o/r/pulls/123) with no `html_url`. Rewrite pulls/issues - * to a github.com web url; Releases / Commits / Discussions lack a clean - * numbered subject url, so fall back to the repository's html_url. - */ -function subjectHtmlUrl(n: RawNotification): string { - const api = n.subject?.url ?? ""; - if (api) { - const m = api.match(/repos\/([^/]+)\/([^/]+)\/(pulls|issues)\/(\d+)/); - if (m) { - const kind = m[3] === "pulls" ? "pull" : "issues"; - return `https://github.com/${m[1]}/${m[2]}/${kind}/${m[4]}`; - } - } - return n.repository?.html_url ?? ""; -} diff --git a/apps/desktop/src/main/github/orgs.ts b/apps/desktop/src/main/github/orgs.ts index c4b85d5..8d8a395 100644 --- a/apps/desktop/src/main/github/orgs.ts +++ b/apps/desktop/src/main/github/orgs.ts @@ -10,7 +10,15 @@ // not used anywhere in this module. import { GitHubClient, enc } from "../githubClient"; -import type { OrgInfo, OrgMember, OrgRepo, OrgTeam } from "../../shared/ipc"; +import { PAGE_CAPS } from "../githubPaging"; +import type { + GhUserInfo, + OrgInfo, + OrgMember, + OrgRepo, + OrgRepoDetail, + OrgTeam, +} from "../../shared/ipc"; // ── Raw GitHub shapes (only the fields we map) ──────────────────────────────── @@ -94,16 +102,16 @@ function mapMember(m: RawMember): OrgMember { /** Orgs the signed-in user has visible membership in. Orgs that hide the user's * membership won't appear — expected GitHub behavior. */ export async function listOrgs(client: GitHubClient): Promise { - const raw = await client.request("GET", `/user/orgs?per_page=100`); + const raw = await client.requestPaged(`/user/orgs?per_page=100`, PAGE_CAPS.account); return raw.map(mapOrg); } /** An org's repositories, most-recently-pushed first. Private repos appear when * the OAuth token also carries `repo`; needs no extra scope of its own. */ export async function listOrgRepos(client: GitHubClient, org: string): Promise { - const raw = await client.request( - "GET", + const raw = await client.requestPaged( `/orgs/${enc(org)}/repos?sort=pushed&direction=desc&per_page=100`, + PAGE_CAPS.account, ); return raw.map(mapRepo); } @@ -111,13 +119,126 @@ export async function listOrgRepos(client: GitHubClient, org: string): Promise { - const raw = await client.request("GET", `/orgs/${enc(org)}/teams?per_page=100`); + const raw = await client.requestPaged(`/orgs/${enc(org)}/teams?per_page=100`, PAGE_CAPS.account); return raw.map(mapTeam); } /** An org's members, per the org's visibility. A non-owner may only see public * members → can be empty even for large orgs; a restricted list yields a 403. */ export async function listOrgMembers(client: GitHubClient, org: string): Promise { - const raw = await client.request("GET", `/orgs/${enc(org)}/members?per_page=100`); + const raw = await client.requestPaged(`/orgs/${enc(org)}/members?per_page=100`, PAGE_CAPS.account); return raw.map(mapMember); } + +/** The full-repo raw shape (only the extra fields the repo peek renders). */ +interface RawRepoDetail extends RawRepo { + clone_url?: string; + ssh_url?: string; + default_branch?: string; + open_issues_count?: number; + forks_count?: number; + topics?: string[]; + license?: { name?: string } | null; + created_at?: string; + homepage?: string | null; +} + +/** One repository's full record ("owner/repo") — the org-repo peek's body. */ +export async function getOrgRepoDetail( + client: GitHubClient, + fullName: string, +): Promise { + const [owner, repo] = fullName.split("/", 2); + const r = await client.request("GET", `/repos/${enc(owner)}/${enc(repo)}`); + return { + fullName: r.full_name, + description: r.description ?? null, + htmlUrl: r.html_url, + cloneUrl: r.clone_url ?? `${r.html_url}.git`, + sshUrl: r.ssh_url ?? "", + defaultBranch: r.default_branch ?? "main", + openIssuesCount: r.open_issues_count ?? 0, + forksCount: r.forks_count ?? 0, + stargazersCount: r.stargazers_count ?? 0, + topics: r.topics ?? [], + license: r.license?.name ?? null, + language: r.language ?? null, + private: r.private ?? false, + archived: r.archived ?? false, + fork: r.fork ?? false, + pushedAt: r.pushed_at ?? "", + createdAt: r.created_at ?? "", + homepage: r.homepage ?? null, + }; +} + +/** A team's members — the team peek's drill-in list. Same visibility rules as + * listOrgTeams (needs read:org; non-members 403 → errorState in the peek). */ +export async function listTeamMembers( + client: GitHubClient, + org: string, + slug: string, +): Promise { + const raw = await client.requestPaged( + `/orgs/${enc(org)}/teams/${enc(slug)}/members?per_page=100`, + PAGE_CAPS.account, + ); + return raw.map(mapMember); +} + +interface RawUser extends RawMember { + name?: string | null; + bio?: string | null; + company?: string | null; + location?: string | null; + blog?: string | null; + followers?: number; + following?: number; + public_repos?: number; + created_at?: string; + /** "User" or "Organization" — an Explore profile page must know which. */ + type?: string; + twitter_username?: string | null; + email?: string | null; +} + +/** A user's public profile — the member peek's body. */ +export async function getUserInfo(client: GitHubClient, login: string): Promise { + const u = await client.request("GET", `/users/${enc(login)}`); + return { + login: u.login, + name: u.name ?? null, + avatarUrl: u.avatar_url ?? null, + bio: u.bio ?? null, + company: u.company ?? null, + location: u.location ?? null, + blog: u.blog ?? null, + htmlUrl: u.html_url ?? `https://github.com/${u.login}`, + followers: u.followers ?? 0, + following: u.following ?? 0, + publicRepos: u.public_repos ?? 0, + createdAt: u.created_at ?? "", + type: u.type ?? "User", + twitter: u.twitter_username ?? null, + email: u.email ?? null, + }; +} + +/** A user's public repositories — the profile page's list. Sorted by GitHub's + * "updated" so the page opens on what they're actually working on. */ +export async function listUserRepos(client: GitHubClient, login: string): Promise { + const raw = await client.requestPaged( + `/users/${enc(login)}/repos?per_page=100&sort=updated&direction=desc`, + PAGE_CAPS.account, + ); + return raw.map(mapRepo); +} + +/** The organizations a user belongs to (public membership only). */ +export async function listUserOrgs(client: GitHubClient, login: string): Promise { + const raw = await client.requestPaged( + `/users/${enc(login)}/orgs?per_page=100`, + PAGE_CAPS.account, + ); + return raw.map(mapOrg); +} diff --git a/apps/desktop/src/main/github/prs.ts b/apps/desktop/src/main/github/prs.ts index 1c4137f..b57be88 100644 --- a/apps/desktop/src/main/github/prs.ts +++ b/apps/desktop/src/main/github/prs.ts @@ -18,7 +18,9 @@ // the client keeps private (RawPull/mapPull) are redefined locally so this // module is self-contained. -import { GitHubClient, enc, mapUser, RawUser } from "../githubClient"; +import { GitHubClient, enc } from "../githubClient"; +import { mapPull, mapUser, type RawPull, type RawUser } from "./maps"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; import type { BranchRef, @@ -36,28 +38,6 @@ import type { // ── Raw API shapes (snake_case, GitHub REST) ────────────────────────────────── -interface RawRef { - ref: string; - sha: string; -} -interface RawPull { - number: number; - title: string; - body: string | null; - state: string; - draft?: boolean; - html_url: string; - user: RawUser | null; - created_at: string; - updated_at: string; - head: RawRef; - base: RawRef; - labels?: { name: string; color: string }[]; - comments?: number; - additions?: number; - deletions?: number; - changed_files?: number; -} interface RawBranch { name: string; } @@ -77,26 +57,6 @@ interface RawContents { size?: number; } -function mapPull(p: RawPull): PullRequest { - return { - number: p.number, - title: p.title, - body: p.body, - state: p.state, - draft: p.draft ?? false, - htmlUrl: p.html_url, - user: mapUser(p.user), - createdAt: p.created_at, - updatedAt: p.updated_at, - head: { ref: p.head.ref, sha: p.head.sha }, - base: { ref: p.base.ref, sha: p.base.sha }, - labels: (p.labels ?? []).map((l) => ({ name: l.name, color: l.color })), - comments: p.comments, - additions: p.additions, - deletions: p.deletions, - changedFiles: p.changed_files, - }; -} function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -303,7 +263,7 @@ export async function prBranches( repo: string, ): Promise { const [branches, def] = await Promise.all([ - client.request("GET", `/repos/${enc(owner)}/${enc(repo)}/branches?per_page=100`), + client.requestPaged(`/repos/${enc(owner)}/${enc(repo)}/branches?per_page=100`, PAGE_CAPS.account), client .request("GET", `/repos/${enc(owner)}/${enc(repo)}`) .then((m) => m.default_branch ?? "main") @@ -323,9 +283,9 @@ export async function prReviewers( repo: string, ): Promise { try { - const raw = await client.request( - "GET", + const raw = await client.requestPaged( `/repos/${enc(owner)}/${enc(repo)}/collaborators?per_page=100`, + PAGE_CAPS.account, ); return raw.map((u) => ({ login: u.login, avatarUrl: u.avatar_url ?? null })); } catch { @@ -443,9 +403,9 @@ export async function labels( owner: string, repo: string, ): Promise { - const raw = await client.request( - "GET", + const raw = await client.requestPaged( `/repos/${enc(owner)}/${enc(repo)}/labels?per_page=100`, + PAGE_CAPS.detail, ); return raw.map((l) => ({ name: l.name, color: l.color, description: l.description ?? null })); } diff --git a/apps/desktop/src/main/github/releases.ts b/apps/desktop/src/main/github/releases.ts index 96b0111..ce59ac8 100644 --- a/apps/desktop/src/main/github/releases.ts +++ b/apps/desktop/src/main/github/releases.ts @@ -11,6 +11,7 @@ // surfaces a 403 from the mutation calls as a normal error message. import { GitHubClient, enc, mapUser, type RawUser } from "../githubClient"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; import type { CommitActionResult, @@ -90,9 +91,9 @@ export async function listReleases( owner: string, repo: string, ): Promise { - const raw = await client.request( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/releases?per_page=50`, + const raw = await client.requestPaged( + `/repos/${enc(owner)}/${enc(repo)}/releases?per_page=100`, + PAGE_CAPS.list, ); return raw.map(mapRelease); } @@ -217,3 +218,36 @@ export async function deleteRelease( }; } } + +/** Upload ONE asset's bytes to a release. Mutation-shaped: never throws. */ +export async function uploadAssetData( + client: GitHubClient, + owner: string, + repo: string, + releaseId: number, + name: string, + data: Uint8Array, + contentType: string, +): Promise { + try { + await client.uploadReleaseAsset(owner, repo, releaseId, name, data, contentType); + return { ok: true, changed: false }; + } catch (err) { + return { ok: false, changed: false, ...errorFields(err) }; + } +} + +/** Delete one release asset by id. Mutation-shaped: never throws. */ +export async function deleteAsset( + client: GitHubClient, + owner: string, + repo: string, + assetId: number, +): Promise { + try { + await client.request("DELETE", `/repos/${enc(owner)}/${enc(repo)}/releases/assets/${assetId}`); + return { ok: true, changed: false }; + } catch (err) { + return { ok: false, changed: false, ...errorFields(err) }; + } +} diff --git a/apps/desktop/src/main/github/repoBrowse.ts b/apps/desktop/src/main/github/repoBrowse.ts new file mode 100644 index 0000000..5c7309d --- /dev/null +++ b/apps/desktop/src/main/github/repoBrowse.ts @@ -0,0 +1,175 @@ +// Browse ANY GitHub repository in-app WITHOUT cloning it — the read side of +// "the native GitHub app". Three read-only REST calls over the contents API: +// a directory listing, a file's text, and the repo README. This is what turns +// an org's repo list from a launcher for github.com into a place you can +// actually LOOK AT code (and then clone, one action later, if it earns it). +// +// All three THROW on API error (clean Error via the client), so the renderer +// paints an errorState + Retry. Notably, a 404 on an ORG repo frequently means +// the org restricts OAuth-app access — the renderer explains that instead of +// showing a bare "Not Found" (the exact "scuffed info" that pushed users back +// to the website). + +import { GitHubClient, enc } from "../githubClient"; +import { PAGE_CAPS } from "../githubPaging"; +import type { GhRepoBranch, GhRepoEntry, GhRepoFile, GhRepoPaths } from "../../shared/ipc"; + +/** Encode a repo-relative path segment-by-segment (slashes must survive). */ +function encPath(path: string): string { + return path.split("/").filter(Boolean).map(encodeURIComponent).join("/"); +} + +/** `?ref=` for a non-default branch/tag/sha, or "" for the default branch. */ +function refParam(ref: string | undefined, sep: "?" | "&" = "?"): string { + return ref ? `${sep}ref=${encodeURIComponent(ref)}` : ""; +} + +/** Split "owner/repo" for the API path. */ +function ownerRepo(fullName: string): string { + const [owner, repo] = fullName.split("/", 2); + return `${enc(owner)}/${enc(repo)}`; +} + +interface RawContent { + name: string; + path: string; + type: "file" | "dir" | "symlink" | "submodule"; + size?: number; + content?: string; + encoding?: string; +} + +/** One directory of a remote repo, dirs first then files, both name-sorted. */ +export async function listRepoDir( + client: GitHubClient, + fullName: string, + path: string, + ref?: string, +): Promise { + const p = encPath(path); + const raw = await client.request( + "GET", + `/repos/${ownerRepo(fullName)}/contents${p ? `/${p}` : ""}?per_page=1000${refParam(ref, "&")}`, + ); + const list = Array.isArray(raw) ? raw : [raw]; + return list + .map((e): GhRepoEntry => ({ + name: e.name, + path: e.path, + type: e.type === "dir" ? "dir" : "file", + size: e.size, + })) + .sort((a, b) => + a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name), + ); +} + +/** Cap remote file reads — the contents API base64-inlines up to 1MB anyway, + * and a quick look never needs more. */ +const MAX_FILE_BYTES = 1024 * 1024; + +/** One file's text from a remote repo. Binary and oversized files are flagged + * rather than dumped — the viewer shows a notice instead of garbage. */ +export async function readRepoFile( + client: GitHubClient, + fullName: string, + path: string, + ref?: string, +): Promise { + const raw = await client.request( + "GET", + `/repos/${ownerRepo(fullName)}/contents/${encPath(path)}${refParam(ref)}`, + ); + const size = raw.size ?? 0; + if (size > MAX_FILE_BYTES || raw.encoding === "none" || !raw.content) { + return { path, text: "", truncated: true, binary: false, size }; + } + const buf = Buffer.from(raw.content, "base64"); + // The classic binary sniff: a NUL byte in the first 8KB. + if (buf.subarray(0, 8192).includes(0)) { + return { path, text: "", truncated: false, binary: true, size }; + } + return { path, text: buf.toString("utf8"), truncated: false, binary: false, size }; +} + +/** The repo's README markdown (GitHub resolves the preferred one), or + * undefined when the repo simply has none — that's a state, not an error. */ +export async function readRepoReadme( + client: GitHubClient, + fullName: string, + ref?: string, +): Promise<{ name: string; text: string } | undefined> { + let raw: RawContent; + try { + raw = await client.request( + "GET", + `/repos/${ownerRepo(fullName)}/readme${refParam(ref)}`, + ); + } catch { + return undefined; + } + if (!raw.content) { + return undefined; + } + return { name: raw.name, text: Buffer.from(raw.content, "base64").toString("utf8") }; +} + +// ── Refs + the whole-tree path index ───────────────────────────────────────── + +interface RawBranchRef { + name: string; + commit?: { sha?: string }; + protected?: boolean; +} + +/** The repo's branches — the ref switcher's options. Paged, because a busy + * repo has hundreds and a single page would silently show the first 100. */ +export async function listRepoBranches( + client: GitHubClient, + fullName: string, +): Promise { + const raw = await client.requestPaged( + `/repos/${ownerRepo(fullName)}/branches?per_page=100`, + PAGE_CAPS.detail, + ); + return raw.map((b) => ({ + name: b.name, + sha: b.commit?.sha ?? "", + protected: b.protected ?? false, + })); +} + +interface RawTree { + tree?: { path?: string; type?: string; size?: number }[]; + truncated?: boolean; +} + +/** Every blob path in the repo, in one request — what makes "go to file" + * possible without walking directories. + * + * GitHub itself truncates enormous trees, and we cap on top of that: 25k + * paths is far past the point where fuzzy search stays useful, and holding + * more in the renderer buys nothing. BOTH truncations are reported, because + * a file search that silently can't see a file is worse than one that says so. + */ +export async function listRepoPaths( + client: GitHubClient, + fullName: string, + ref?: string, +): Promise { + const target = ref || "HEAD"; + const raw = await client.request( + "GET", + `/repos/${ownerRepo(fullName)}/git/trees/${encodeURIComponent(target)}?recursive=1`, + ); + const all = (raw.tree ?? []).filter((t) => t.type === "blob" && t.path); + const capped = all.slice(0, MAX_PATHS); + return { + paths: capped.map((t) => t.path as string), + truncated: (raw.truncated ?? false) || all.length > MAX_PATHS, + total: all.length, + }; +} + +/** Past this, fuzzy search is noise and the payload is a liability. */ +const MAX_PATHS = 25_000; diff --git a/apps/desktop/src/main/github/search.ts b/apps/desktop/src/main/github/search.ts new file mode 100644 index 0000000..92f6f57 --- /dev/null +++ b/apps/desktop/src/main/github/search.ts @@ -0,0 +1,193 @@ +// Global GitHub search — the data half of the Explore page. +// +// Unlike every other section here, search is ACCOUNT-scoped and metered on its +// own budget (30/min, 10/min for code). So the model is deliberately one API +// request per invoke: an explicit page of 30, never an automatic Link-follow. +// Pagination is a user action ("Load more"), because each page is real money +// out of a small purse. +// +// Every failure mode the UI must distinguish is expressed in the RESULT, not +// as a throw: `limited` (our own budget says wait), `incomplete` (GitHub gave +// up early), and `hasMore: false` at the hard 1000-result ceiling. + +import { GitHubClient } from "../githubClient"; +import { SearchGuard } from "./searchGuard"; +import { + beyondCeiling, + codeSearchPath, + normalizeQuery, + repoSearchPath, + userSearchPath, + SEARCH_PER_PAGE, + SEARCH_RESULT_CEILING, + type RepoSort, +} from "./searchQuery"; +import { mapUser, type RawUser } from "./maps"; +import type { + SearchCodeItem, + SearchPage, + SearchRepoItem, + SearchUserItem, +} from "../../shared/ipc"; + +/** The process-wide budget. One guard for the app: two windows fighting over + * the same GitHub quota is exactly how you earn a surprise 403. */ +const guard = new SearchGuard(); + +interface RawSearchEnvelope { + total_count?: number; + incomplete_results?: boolean; + items?: T[]; +} + +interface RawSearchRepo { + id: number; + full_name: string; + owner?: RawUser | null; + description?: string | null; + language?: string | null; + stargazers_count?: number; + forks_count?: number; + open_issues_count?: number; + updated_at?: string; + pushed_at?: string; + private?: boolean; + fork?: boolean; + archived?: boolean; + topics?: string[]; + license?: { spdx_id?: string | null; name?: string } | null; + html_url?: string; + default_branch?: string; +} + +interface RawSearchCode { + name?: string; + path?: string; + html_url?: string; + repository?: { full_name?: string } | null; + text_matches?: { fragment?: string }[]; +} + +function mapRepo(r: RawSearchRepo): SearchRepoItem { + return { + id: r.id, + fullName: r.full_name, + owner: r.owner?.login ?? r.full_name.split("/")[0] ?? "", + ownerAvatarUrl: r.owner?.avatar_url ?? null, + description: r.description ?? null, + language: r.language ?? null, + stars: r.stargazers_count ?? 0, + forks: r.forks_count ?? 0, + openIssues: r.open_issues_count ?? 0, + updatedAt: r.updated_at ?? "", + pushedAt: r.pushed_at ?? "", + private: r.private ?? false, + fork: r.fork ?? false, + archived: r.archived ?? false, + topics: r.topics ?? [], + license: r.license?.spdx_id && r.license.spdx_id !== "NOASSERTION" ? r.license.spdx_id : null, + htmlUrl: r.html_url ?? `https://github.com/${r.full_name}`, + defaultBranch: r.default_branch ?? "", + }; +} + +function mapCode(c: RawSearchCode): SearchCodeItem { + return { + name: c.name ?? "", + path: c.path ?? "", + repoFullName: c.repository?.full_name ?? "", + htmlUrl: c.html_url ?? "", + fragments: (c.text_matches ?? []) + .map((m) => m.fragment ?? "") + .filter((f) => f.trim().length > 0), + }; +} + +/** An empty page — what an empty query returns without spending anything. */ +function emptyPage(): SearchPage { + return { items: [], totalCount: 0, incomplete: false, hasMore: false }; +} + +/** Shared envelope handling: budget check → fetch → page metadata. */ +async function runSearch( + client: GitHubClient, + o: { + query: string; + page: number; + category: "core" | "code"; + path: string; + map: (raw: Raw) => Item; + accept?: string; + }, +): Promise> { + if (!normalizeQuery(o.query)) return emptyPage(); + // Past the ceiling GitHub answers 422 — refuse locally and keep the budget. + if (beyondCeiling(o.page)) { + return { items: [], totalCount: SEARCH_RESULT_CEILING, incomplete: false, hasMore: false }; + } + const claim = guard.take(o.category); + if (!claim.ok) { + return { ...emptyPage(), limited: { retryInMs: claim.retryInMs } }; + } + const env = await client.request>("GET", o.path, undefined, { + accept: o.accept, + }); + const items = (env.items ?? []).map(o.map); + const total = env.total_count ?? items.length; + return { + items, + totalCount: total, + incomplete: env.incomplete_results ?? false, + // More exists only if GitHub has more AND the next page is reachable. + hasMore: items.length === SEARCH_PER_PAGE && !beyondCeiling(o.page + 1) && o.page * SEARCH_PER_PAGE < total, + }; +} + +export function searchRepos( + client: GitHubClient, + req: { query: string; sort?: RepoSort; page?: number }, +): Promise> { + const page = req.page ?? 1; + return runSearch(client, { + query: req.query, + page, + category: "core", + path: repoSearchPath(req.query, req.sort ?? "best", page), + map: mapRepo, + }); +} + +export function searchUsers( + client: GitHubClient, + req: { query: string; kind: "users" | "orgs"; page?: number }, +): Promise> { + const page = req.page ?? 1; + return runSearch(client, { + query: req.query, + page, + category: "core", + path: userSearchPath(req.query, req.kind, page), + map: (u) => ({ + login: mapUser(u)?.login ?? u.login, + avatarUrl: u.avatar_url ?? null, + htmlUrl: u.html_url ?? `https://github.com/${u.login}`, + type: u.type ?? (req.kind === "orgs" ? "Organization" : "User"), + }), + }); +} + +export function searchCode( + client: GitHubClient, + req: { query: string; page?: number }, +): Promise> { + const page = req.page ?? 1; + return runSearch(client, { + query: req.query, + page, + category: "code", + path: codeSearchPath(req.query, page), + map: mapCode, + // The text-match media type is what turns a file list into readable hits. + accept: "application/vnd.github.text-match+json", + }); +} diff --git a/apps/desktop/src/main/github/searchGuard.ts b/apps/desktop/src/main/github/searchGuard.ts new file mode 100644 index 0000000..cf9d8fc --- /dev/null +++ b/apps/desktop/src/main/github/searchGuard.ts @@ -0,0 +1,60 @@ +// Search rate budget. +// +// GitHub's search API is metered separately from core REST — 30 requests per +// minute for repositories/users, and only 10 for code. Blowing through that +// earns a 403 that looks, to a user, exactly like "the app is broken". +// +// So we spend the budget deliberately: a token bucket per category, checked +// BEFORE the request. When there's nothing left we return a structured +// `limited { retryInMs }` instead of firing and hoping — the UI can then say +// "one moment" honestly, with a countdown, rather than showing an error for a +// condition that resolves itself in seconds. +// +// Pure and clock-injectable, so the whole policy is node-testable. + +export type SearchCategory = "core" | "code"; + +/** Requests per minute GitHub allows an authenticated user, per category. */ +export const SEARCH_LIMITS: Record = { + core: 30, + code: 10, +}; + +const WINDOW_MS = 60_000; +/** Leave a little headroom — other GitStudio surfaces share the same budget. */ +const RESERVE = 2; + +export class SearchGuard { + /** Timestamps of recent spends, per category (oldest first). */ + private readonly spent: Record = { core: [], code: [] }; + + constructor(private readonly now: () => number = () => Date.now()) {} + + /** Drop spends that have aged out of the window. */ + private prune(cat: SearchCategory): number[] { + const cutoff = this.now() - WINDOW_MS; + const kept = this.spent[cat].filter((t) => t > cutoff); + this.spent[cat] = kept; + return kept; + } + + /** Requests still available in this window. */ + remaining(cat: SearchCategory): number { + return Math.max(0, SEARCH_LIMITS[cat] - RESERVE - this.prune(cat).length); + } + + /** + * Claim one request. Returns `{ ok: true }` when the caller may proceed, or + * `{ ok: false, retryInMs }` — the wait until the oldest spend ages out. + */ + take(cat: SearchCategory): { ok: true } | { ok: false; retryInMs: number } { + const kept = this.prune(cat); + if (kept.length < SEARCH_LIMITS[cat] - RESERVE) { + kept.push(this.now()); + return { ok: true }; + } + const oldest = kept[0] ?? this.now(); + // +1s so a retry scheduled at exactly this time doesn't land a tick early. + return { ok: false, retryInMs: Math.max(0, oldest + WINDOW_MS - this.now()) + 1_000 }; + } +} diff --git a/apps/desktop/src/main/github/searchQuery.ts b/apps/desktop/src/main/github/searchQuery.ts new file mode 100644 index 0000000..6238189 --- /dev/null +++ b/apps/desktop/src/main/github/searchQuery.ts @@ -0,0 +1,84 @@ +// Pure query + path builders for GitHub search. +// +// Split from the fetchers so every URL this app can ask for is node-testable +// without a network — search paths are the easiest place to quietly build a +// wrong query (an unescaped qualifier, a sort GitHub rejects) and the hardest +// place to notice, since a wrong query returns results, just not the right +// ones. + +export type SearchKind = "repos" | "users" | "orgs" | "code"; +export type RepoSort = "best" | "stars" | "updated"; + +/** GitHub caps every search at 1000 results, however many it claims to match. */ +export const SEARCH_RESULT_CEILING = 1000; +export const SEARCH_PER_PAGE = 30; + +/** Trim, collapse whitespace — the form a query is cached and compared by. */ +export function normalizeQuery(raw: string): string { + return raw.trim().replace(/\s+/g, " "); +} + +/** + * The `q` for a user/org search. GitHub has no "orgs" endpoint — orgs are + * users with `type:org`, and the qualifier is what separates the two tabs. + * A user-supplied `type:` is left alone: someone who types it means it. + */ +export function userQuery(query: string, kind: "users" | "orgs"): string { + const q = normalizeQuery(query); + if (/\btype:\s*\S+/i.test(q)) return q; + return `${q} type:${kind === "orgs" ? "org" : "user"}`; +} + +/** `sort`/`order` params for a repo search; "best" means GitHub's own ranking + * (which is expressed by sending NO sort at all). */ +export function repoSortParams(sort: RepoSort): Record { + if (sort === "stars") return { sort: "stars", order: "desc" }; + if (sort === "updated") return { sort: "updated", order: "desc" }; + return {}; +} + +function qs(params: Record): string { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) sp.set(k, String(v)); + return sp.toString(); +} + +/** `/search/repositories?…` for one page (1-based). */ +export function repoSearchPath(query: string, sort: RepoSort, page = 1): string { + return `/search/repositories?${qs({ + q: normalizeQuery(query), + per_page: SEARCH_PER_PAGE, + page, + ...repoSortParams(sort), + })}`; +} + +/** `/search/users?…` for one page — `kind` picks the type: qualifier. */ +export function userSearchPath(query: string, kind: "users" | "orgs", page = 1): string { + return `/search/users?${qs({ + q: userQuery(query, kind), + per_page: SEARCH_PER_PAGE, + page, + })}`; +} + +/** `/search/code?…` for one page. */ +export function codeSearchPath(query: string, page = 1): string { + return `/search/code?${qs({ + q: normalizeQuery(query), + per_page: SEARCH_PER_PAGE, + page, + })}`; +} + +/** True when this page would reach past GitHub's hard 1000-result ceiling — + * asking anyway earns a 422, so the UI stops offering "Load more" instead. */ +export function beyondCeiling(page: number): boolean { + return page * SEARCH_PER_PAGE > SEARCH_RESULT_CEILING; +} + +/** How many of `total` are actually reachable. The difference is what the UI + * must be honest about: "1,284 matches, first 1,000 available". */ +export function reachableCount(total: number): number { + return Math.min(total, SEARCH_RESULT_CEILING); +} diff --git a/apps/desktop/src/main/githubBridge.ts b/apps/desktop/src/main/githubBridge.ts index 803d2fe..bb075ee 100644 --- a/apps/desktop/src/main/githubBridge.ts +++ b/apps/desktop/src/main/githubBridge.ts @@ -5,8 +5,8 @@ // same `status/connect` surface; the renderer only knows about connect/disconnect // plus the data calls. -import { app, safeStorage } from "electron"; -import { readFile, unlink, access } from "node:fs/promises"; +import { app } from "electron"; +import { unlink } from "node:fs/promises"; import { join } from "node:path"; import { SecretStore } from "@gitstudio/secret-store/secretStore"; @@ -16,6 +16,10 @@ import { GitHubClient } from "./githubClient"; import { requestDeviceCode, pollForToken } from "./githubAuth"; import type { RepoStore } from "./repoStore"; import { ExpectedError } from "./expectedError"; +import { parseGitHubRemote } from "./githubRemote"; + +// Re-exported so existing importers (and their tests) keep their seam. +export { parseGitHubRemote } from "./githubRemote"; import { errorFields } from "./githubErrors"; import type { CheckRun, @@ -34,6 +38,7 @@ import type { WorkflowRun, } from "../shared/ipc"; + export class GitHubBridge { private token: string | undefined; private login: string | undefined; @@ -63,61 +68,54 @@ export class GitHubBridge { this.loaded = true; this.token = await this.secrets().get(TOKEN_SECRET); if (this.token === undefined) { - this.token = await this.adoptLegacyToken(); + // NO KEYRING, EVER. A pre-1.4 safeStorage blob could only be read + // through the OS keychain, whose ACL is bound to the app's code + // signature -- so every rebuilt/re-signed binary raised the macOS + // password prompt again, and declining it made sign-in look broken. + // That migration is gone: any leftover blob is deleted unread, and the + // user signs in once more through the (now prompt-free) device flow. + await unlink(this.legacyTokenPath()).catch(() => {}); } } - /** - * Move a pre-1.4 safeStorage token into the keychain-free store, once. - * - * The only remaining path that can raise an OS password prompt, and it is - * reached only from `ensureLoaded`, which callers now invoke exclusively - * before a real GitHub request — never from `status()`, never at launch. After - * a successful adopt the legacy blob is deleted, so this costs at most one - * prompt, ever. Declining it just leaves GitHub disconnected for the session. - */ - private async adoptLegacyToken(): Promise { - let buf: Buffer; - try { - buf = await readFile(this.legacyTokenPath()); - } catch { - return undefined; // no stored token — the keyring is never touched - } - // We never persisted a plaintext token, so a blob we can't decrypt is junk. - if (!safeStorage.isEncryptionAvailable()) { - return undefined; - } - let token: string; - try { - token = safeStorage.decryptString(buf); - } catch { - return undefined; // stored by a different machine/user - } - await this.secrets().set(TOKEN_SECRET, token); - await unlink(this.legacyTokenPath()).catch(() => {}); - return token; - } - /** Resolve owner/repo from `git remote get-url origin` (cached per repo root). */ + /** Resolve owner/repo from the repo's remotes (positive result cached per + * root). Tries `origin`, then `upstream`, then every other remote — a fork + * checked out with only an `upstream` remote used to be refused outright. + * A miss is NOT cached: adding a remote after opening the repo starts + * working on the very next call instead of after a repo switch. */ private async resolveOwnerRepo(): Promise<{ owner: string; repo: string } | undefined> { const ctx = this.repos.getContext(); if (!ctx) { return undefined; } - if (this.ownerRepoRoot === ctx.root) { + if (this.ownerRepoRoot === ctx.root && this.cachedOwnerRepo) { return this.cachedOwnerRepo; } - let url = ""; - try { - const r = await ctx.process.run(["remote", "get-url", "origin"]); - url = r.stdout.trim(); - } catch { - url = ""; + const tryRemote = async (name: string): Promise<{ owner: string; repo: string } | undefined> => { + try { + const r = await ctx.process.run(["remote", "get-url", name]); + return r.code === 0 ? parseGitHubRemote(r.stdout.trim()) : undefined; + } catch { + return undefined; + } + }; + let hit = (await tryRemote("origin")) ?? (await tryRemote("upstream")); + if (!hit) { + try { + const r = await ctx.process.run(["remote"]); + for (const name of r.stdout.split("\n").map((s) => s.trim()).filter(Boolean)) { + if (name === "origin" || name === "upstream") continue; + hit = await tryRemote(name); + if (hit) break; + } + } catch { + // no remotes at all + } } - const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i); this.ownerRepoRoot = ctx.root; - this.cachedOwnerRepo = m ? { owner: m[1], repo: m[2] } : undefined; - return this.cachedOwnerRepo; + this.cachedOwnerRepo = hit; + return hit; } async status(): Promise { @@ -143,17 +141,12 @@ export class GitHubBridge { return { connected: false, repo }; } - /** Is there a stored token, WITHOUT decrypting it? */ + /** Is there a stored token, WITHOUT decrypting it? Only the keychain-free + * store counts — a leftover pre-1.4 keyring blob is unreadable by design + * (see ensureLoaded), so treating it as "connected" showed a signed-in UI + * whose every real request then failed. */ private async hasStoredToken(): Promise { - if (this.secrets().has(TOKEN_SECRET)) { - return true; - } - try { - await access(this.legacyTokenPath()); - return true; - } catch { - return false; - } + return this.secrets().has(TOKEN_SECRET); } async connect(pat: string): Promise<{ ok: boolean; login?: string; message?: string }> { @@ -416,12 +409,6 @@ export class GitHubBridge { return { ok: false, changed: false, ...errorFields(err) }; } } - async actionsRuns(): Promise { - const r = await this.resolveOwnerRepo(); - if (!r || !this.token) return []; - return this.client.listWorkflowRuns(r.owner, r.repo); - } - async issueList(): Promise { const r = await this.resolveOwnerRepo(); if (!r || !this.token) { diff --git a/apps/desktop/src/main/githubClient.ts b/apps/desktop/src/main/githubClient.ts index 53ace53..2c0c0e8 100644 --- a/apps/desktop/src/main/githubClient.ts +++ b/apps/desktop/src/main/githubClient.ts @@ -6,6 +6,8 @@ import { ExpectedError } from "./expectedError"; import { githubHttpError, graphqlError, networkError } from "./githubErrors"; +import { nextPagePath, PAGE_CAPS } from "./githubPaging"; +import { mapIssue, mapPull, mapUser, type RawIssue, type RawPull, type RawUser } from "./github/maps"; import type { CheckRun, GitHubUser, @@ -31,17 +33,24 @@ export type TokenGetter = () => string | undefined; export class GitHubClient { constructor(private readonly getToken: TokenGetter) {} - /** REST call returning the parsed JSON body. `body` (POST/PATCH/PUT) is sent as - * JSON. Throws a clean Error on non-2xx or network failure. Public so the - * per-section modules under ./github can call it. */ - async request(method: string, path: string, body?: unknown): Promise { + /** The shared fetch under `request`/`requestPaged`: auth headers, timeout, + * network-error wrapping, non-2xx → githubHttpError. Returns the RESPONSE so + * paged callers can read the `Link` header. */ + private async fetchRes( + method: string, + path: string, + body?: unknown, + opts?: { accept?: string }, + ): Promise { const token = this.getToken(); if (!token) { throw new ExpectedError("Not connected to GitHub."); } const headers: Record = { Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", + // Some endpoints need a different media type to return the good stuff — + // code search only includes match fragments under text-match+json. + Accept: opts?.accept ?? "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "GitStudio", }; @@ -54,16 +63,123 @@ export class GitHubClient { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, + // A hung request used to hang every gated section on its skeleton + // forever — the API must fail fast enough for the UI to say so. + signal: AbortSignal.timeout(20_000), }); } catch { throw networkError(); } - if (res.ok) { - if (res.status === 204) return undefined as T; + if (!res.ok) { + throw await githubHttpError(res); + } + return res; + } + + /** REST call returning the parsed JSON body. `body` (POST/PATCH/PUT) is sent as + * JSON. Throws a clean Error on non-2xx or network failure. Public so the + * per-section modules under ./github can call it. */ + async request( + method: string, + path: string, + body?: unknown, + opts?: { accept?: string }, + ): Promise { + const res = await this.fetchRes(method, path, body, opts); + if (res.status === 204) return undefined as T; + const text = await res.text(); + return (text.length > 0 ? JSON.parse(text) : undefined) as T; + } + + /** + * GET an ARRAY-bodied list endpoint, following the `Link: rel="next"` chain + * up to `maxPages` pages and concatenating the results. The first page's + * failure throws; a FOLLOW-UP page's failure returns what was gathered so far + * (a partial long list beats an error state the user already had data for). + */ + async requestPaged(path: string, maxPages: number): Promise { + const out: T[] = []; + let next: string | undefined = path; + for (let page = 0; next && page < maxPages; page++) { + let res: Response; + try { + res = await this.fetchRes("GET", next); + } catch (e) { + if (page === 0) throw e; + break; + } const text = await res.text(); - return (text.length > 0 ? JSON.parse(text) : undefined) as T; + const chunk = (text.length > 0 ? JSON.parse(text) : []) as T[]; + out.push(...chunk); + next = nextPagePath(res.headers.get("link"), API_BASE); + } + return out; + } + + /** + * As {@link requestPaged}, for OBJECT-bodied list endpoints (`{ total_count, + * workflow_runs: [...] }` and friends) — `key` names the array to gather. + */ + async requestPagedKey(path: string, key: string, maxPages: number): Promise { + const out: T[] = []; + let next: string | undefined = path; + for (let page = 0; next && page < maxPages; page++) { + let res: Response; + try { + res = await this.fetchRes("GET", next); + } catch (e) { + if (page === 0) throw e; + break; + } + const text = await res.text(); + const body = (text.length > 0 ? JSON.parse(text) : {}) as Record; + const chunk = body[key]; + if (Array.isArray(chunk)) out.push(...(chunk as T[])); + next = nextPagePath(res.headers.get("link"), API_BASE); + } + return out; + } + + /** + * Upload one release asset (binary) to uploads.github.com — a different host + * than the API base, hence its own fetch. Generous timeout: installers are + * hundreds of megabytes. + */ + async uploadReleaseAsset( + owner: string, + repo: string, + releaseId: number, + name: string, + data: Uint8Array, + contentType: string, + ): Promise { + const token = this.getToken(); + if (!token) { + throw new ExpectedError("Not connected to GitHub."); + } + let res: Response; + try { + res = await fetch( + `https://uploads.github.com/repos/${enc(owner)}/${enc(repo)}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "GitStudio", + "Content-Type": contentType, + }, + body: data as unknown as RequestInit["body"], + signal: AbortSignal.timeout(300_000), + }, + ); + } catch { + throw networkError(); + } + if (!res.ok) { + throw await githubHttpError(res); } - throw await githubHttpError(res); } /** REST call that ignores the response body (fire-and-forget mutations). */ @@ -79,6 +195,7 @@ export class GitHubClient { try { res = await fetch(`${API_BASE}${path}`, { method, + signal: AbortSignal.timeout(30_000), headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", @@ -143,9 +260,9 @@ export class GitHubClient { // ── Pull requests ── async listOpenPulls(owner: string, repo: string): Promise { - const raw = await this.request( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/pulls?state=open&sort=updated&direction=desc&per_page=50`, + const raw = await this.requestPaged( + `/repos/${enc(owner)}/${enc(repo)}/pulls?state=open&sort=updated&direction=desc&per_page=100`, + PAGE_CAPS.list, ); return raw.map(mapPull); } @@ -153,9 +270,9 @@ export class GitHubClient { return mapPull(await this.request("GET", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}`)); } async getPullFiles(owner: string, repo: string, n: number): Promise { - const raw = await this.request( - "GET", + const raw = await this.requestPaged( `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/files?per_page=100`, + PAGE_CAPS.detail, ); return raw.map((f) => ({ filename: f.filename, @@ -171,9 +288,9 @@ export class GitHubClient { await this.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/reviews`, { event: "APPROVE" }); } async listPrCommits(owner: string, repo: string, n: number): Promise { - const raw = await this.request( - "GET", + const raw = await this.requestPaged( `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/commits?per_page=100`, + PAGE_CAPS.detail, ); return raw.map((c) => ({ sha: c.sha, @@ -186,8 +303,8 @@ export class GitHubClient { /** The conversation = issue comments + reviews, merged chronologically. */ async listConversation(owner: string, repo: string, n: number): Promise { const [comments, reviews] = await Promise.all([ - this.request("GET", `/repos/${enc(owner)}/${enc(repo)}/issues/${n}/comments?per_page=100`).catch(() => []), - this.request("GET", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/reviews?per_page=100`).catch(() => []), + this.requestPaged(`/repos/${enc(owner)}/${enc(repo)}/issues/${n}/comments?per_page=100`, PAGE_CAPS.detail).catch(() => []), + this.requestPaged(`/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/reviews?per_page=100`, PAGE_CAPS.detail).catch(() => []), ]); const out: PrComment[] = []; for (const c of comments) { @@ -216,26 +333,6 @@ export class GitHubClient { return []; } } - async listWorkflowRuns(owner: string, repo: string): Promise { - try { - const raw = await this.request<{ workflow_runs?: RawRun[] }>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/actions/runs?per_page=30`, - ); - return (raw.workflow_runs ?? []).map((r) => ({ - id: r.id, - name: r.name ?? r.display_title ?? "(run)", - status: r.status ?? "", - conclusion: r.conclusion ?? "", - branch: r.head_branch ?? "", - event: r.event ?? "", - createdAt: r.created_at ?? "", - htmlUrl: r.html_url ?? "", - })); - } catch { - return []; - } - } async getCombinedStatus(owner: string, repo: string, ref: string): Promise { try { const raw = await this.request<{ state?: string; total_count?: number }>( @@ -250,9 +347,9 @@ export class GitHubClient { // ── Issues (the issues endpoint also returns PRs — filter them out) ── async listOpenIssues(owner: string, repo: string): Promise { - const raw = await this.request( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/issues?state=open&sort=updated&direction=desc&per_page=50`, + const raw = await this.requestPaged( + `/repos/${enc(owner)}/${enc(repo)}/issues?state=open&sort=updated&direction=desc&per_page=100`, + PAGE_CAPS.list, ); return raw.filter((i) => !i.pull_request).map(mapIssue); } @@ -288,52 +385,20 @@ export function enc(part: string): string { return encodeURIComponent(part); } -export interface RawUser { - login: string; - avatar_url?: string; -} +// RawUser + mapUser live in github/maps.ts now (imported above) — re-exported +// here so the per-section modules' existing `import { mapUser } from +// "../githubClient"` keeps working. +export { mapUser, type RawUser }; interface RawRef { ref: string; sha: string; } -interface RawPull { - number: number; - title: string; - body: string | null; - state: string; - draft?: boolean; - html_url: string; - user: RawUser | null; - created_at: string; - updated_at: string; - head: RawRef; - base: RawRef; - labels?: { name: string; color: string }[]; - comments?: number; - additions?: number; - deletions?: number; - changed_files?: number; -} interface RawFile { filename: string; status: string; additions: number; deletions: number; } -interface RawIssue { - number: number; - title: string; - body: string | null; - state: string; - html_url: string; - user: RawUser | null; - created_at: string; - updated_at: string; - comments: number; - labels?: ({ name: string; color: string } | string)[]; - assignees?: RawUser[]; - pull_request?: unknown; -} interface RawPrCommit { sha: string; commit?: { message?: string; author?: { name?: string; date?: string } }; @@ -356,17 +421,6 @@ interface RawCheck { conclusion?: string; details_url?: string; } -interface RawRun { - id: number; - name?: string; - display_title?: string; - status?: string; - conclusion?: string; - head_branch?: string; - event?: string; - created_at?: string; - html_url?: string; -} interface RawProjectsData { repository?: { projectsV2?: { @@ -384,45 +438,3 @@ interface RawProjectsData { }; } -export function mapUser(u: RawUser | null | undefined): GitHubUser | null { - return u ? { login: u.login, avatarUrl: u.avatar_url ?? null } : null; -} -function mapPull(p: RawPull): PullRequest { - return { - number: p.number, - title: p.title, - body: p.body, - state: p.state, - draft: p.draft ?? false, - htmlUrl: p.html_url, - user: mapUser(p.user), - createdAt: p.created_at, - updatedAt: p.updated_at, - head: { ref: p.head.ref, sha: p.head.sha }, - base: { ref: p.base.ref, sha: p.base.sha }, - labels: (p.labels ?? []).map((l) => ({ name: l.name, color: l.color })), - comments: p.comments, - additions: p.additions, - deletions: p.deletions, - changedFiles: p.changed_files, - }; -} -function mapIssue(i: RawIssue): IssueInfo { - return { - number: i.number, - title: i.title, - body: i.body, - state: i.state, - htmlUrl: i.html_url, - user: mapUser(i.user), - createdAt: i.created_at, - updatedAt: i.updated_at, - comments: i.comments, - labels: (i.labels ?? []).map((l) => - typeof l === "string" ? { name: l, color: "888888" } : { name: l.name, color: l.color }, - ), - assignees: (i.assignees ?? []) - .map(mapUser) - .filter((u): u is GitHubUser => u !== null), - }; -} diff --git a/apps/desktop/src/main/githubPaging.ts b/apps/desktop/src/main/githubPaging.ts new file mode 100644 index 0000000..d4423b7 --- /dev/null +++ b/apps/desktop/src/main/githubPaging.ts @@ -0,0 +1,50 @@ +// Pure pagination helpers for the GitHub REST client — kept in their own +// module (no client / Electron imports) so they unit-test in isolation. +// +// GitHub paginates list endpoints with an RFC-5988 `Link` response header: +// ; rel="next", +// ; rel="last" +// Following rel="next" until it disappears (or a page cap) is the ONLY correct +// way to read a full list — `per_page=100` alone silently truncates busy repos. + +/** + * The rel="next" target from a `Link` header, as a PATH relative to `apiBase` + * (the form `GitHubClient.request` takes), or undefined on the last page. + * A rel="next" pointing at a different host is ignored — we never follow a + * redirect off api.github.com. + */ +export function nextPagePath( + linkHeader: string | null | undefined, + apiBase: string, +): string | undefined { + if (!linkHeader) return undefined; + for (const part of linkHeader.split(",")) { + const m = /<([^>]+)>\s*;\s*(?:[^,]*;\s*)?rel="next"/.exec(part.trim()); + if (!m) continue; + const url = m[1]; + if (url.startsWith(apiBase)) return url.slice(apiBase.length); + // A relative path is fine too (not what GitHub sends, but harmless). + if (url.startsWith("/")) return url; + return undefined; // absolute URL on some other host — refuse to follow + } + return undefined; +} + +/** + * Page caps per surface: how many pages `requestPaged` follows before stopping. + * Deliberate ceilings — a repo with 4,000 open issues should not stall the + * section behind 40 sequential requests. When a list comes back at exactly + * cap × per_page items, the UI says "showing the first N" instead of lying. + */ +export const PAGE_CAPS = { + /** Issues / PRs: 3 × 100 = 300 items. */ + list: 3, + /** Workflow runs (heavy payloads): 2 × 100 = 200 runs. */ + runs: 2, + /** Timeline comments / reviews / files / commits on one item: 3 × 100. */ + detail: 3, + /** Org repos / members, branches, gists: 3 × 100. */ + account: 3, + /** Notifications (endpoint max per_page=50): 3 × 50 = 150 threads. */ + notifications: 3, +} as const; diff --git a/apps/desktop/src/main/githubRemote.ts b/apps/desktop/src/main/githubRemote.ts new file mode 100644 index 0000000..f8df950 --- /dev/null +++ b/apps/desktop/src/main/githubRemote.ts @@ -0,0 +1,37 @@ +// The `origin` remote parser — pure, dependency-free, and deliberately in its +// OWN module: githubBridge imports electron, and the local-repo scanner (plus +// its node tests) must not drag that in just to read a remote URL. + +/** + * Parse a git remote URL into github.com owner/repo — or undefined when it + * isn't github.com. Handles what real machines actually have: + * https://github.com/o/r(.git)(/) — plus http + * git@github.com:o/r.git — scp-like + * git@github.com-work:o/r.git — SSH host ALIASES (multi-account) + * ssh://git@github.com:22/o/r.git — ssh with a port (the old regex + * parsed owner="22" and 404'd forever) + * git://github.com/o/r + * Host-anchored: "evilnotgithub.com" never matches. + */ +export function parseGitHubRemote(url: string): { owner: string; repo: string } | undefined { + const u = url.trim(); + if (!u) return undefined; + let path: string | undefined; + // scp-like: [user@]HOST:path — HOST must be github.com or a github.com-* alias. + let m = /^(?:[\w.-]+@)?github\.com(?:-[\w.-]+)?:([^/].*)$/i.exec(u); + if (m) path = m[1]; + if (!path) { + // URL forms: scheme://[user@]github.com[:port]/path + m = /^(?:https?|ssh|git|git\+ssh):\/\/(?:[\w.-]+@)?github\.com(?:-[\w.-]+)?(?::\d+)?\/(.+)$/i.exec(u); + if (m) path = m[1]; + } + if (!path) return undefined; + const parts = path + .replace(/\.git\/?$/i, "") + .replace(/\/+$/, "") + .split("/") + .filter(Boolean); + // Exactly owner/repo — github.com has no deeper namespaces. + if (parts.length !== 2) return undefined; + return { owner: parts[0], repo: parts[1] }; +} diff --git a/apps/desktop/src/main/localRepos.ts b/apps/desktop/src/main/localRepos.ts new file mode 100644 index 0000000..cc1ac30 --- /dev/null +++ b/apps/desktop/src/main/localRepos.ts @@ -0,0 +1,243 @@ +// The local-copies scanner behind Settings → Repositories. +// +// GitStudio clones into a folder the user controls (appSettings.cloneDir), and +// it remembers repos opened from anywhere else. Neither list alone answers +// "what do I actually have on this machine?" — so this module unions them: +// +// top-level dirs of the clone folder ∪ the recents list +// +// Each candidate is probed for its `origin` remote (so a row can say WHICH +// GitHub repo it is, not just which folder), deduped by real path, and cached +// for 30s — the settings card re-renders freely without re-shelling out to git +// dozens of times. +// +// Electron-free by design (every path and the clock are injected) so it runs +// under plain node in tests. + +import { execFile } from "node:child_process"; +import { readdir, realpath, stat } from "node:fs/promises"; +import { basename, dirname, join, resolve, sep } from "node:path"; +import { parseGitHubRemote } from "./githubRemote"; +import type { LocalCopy } from "../shared/ipc"; + +/** Don't shell out to git hundreds of times for a huge folder. */ +const MAX_ENTRIES = 300; +/** Parallel `git remote get-url` probes. */ +const PROBE_CONCURRENCY = 8; +const PROBE_TIMEOUT_MS = 5_000; +export const SCAN_TTL_MS = 30_000; + +export interface ScanInput { + /** The configured clone folder (its top-level dirs are candidates). */ + cloneDir: string; + /** Recently-opened repo roots (candidates from anywhere on disk). */ + recents: string[]; + /** The repo the app currently has open, if any. */ + current?: string; +} + +/** `git -C root remote get-url origin`, parsed to "owner/repo" — or undefined. */ +function originOf(root: string): Promise { + return new Promise((res) => { + execFile( + "git", + ["-C", root, "remote", "get-url", "origin"], + { timeout: PROBE_TIMEOUT_MS }, + (err, stdout) => { + if (err) return res(undefined); + const parsed = parseGitHubRemote(stdout.trim()); + res(parsed ? `${parsed.owner}/${parsed.repo}` : undefined); + }, + ); + }); +} + +/** Two paths that name the same place, compared the way the rest of this + * module does (resolve only — realpath needs I/O and a missing path has none). */ +export function samePath(a: string, b: string): boolean { + return resolve(a) === resolve(b); +} + +/** True when `child` is `parent` itself or sits underneath it. */ +export function isInside(parent: string, child: string): boolean { + const p = resolve(parent); + const c = resolve(child); + return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep); +} + +/** realpath, falling back to a plain resolve when the path doesn't exist. + * Load-bearing on macOS, where /var is a symlink to /private/var: comparing a + * resolved repo path against an UNresolved clone dir marks every managed + * clone as unmanaged (and so undeletable). */ +async function realOrResolve(p: string): Promise { + try { + return await realpath(p); + } catch { + // The path itself is gone — resolve its PARENT instead, so a missing entry + // is still judged against the same real prefix as everything else. Without + // this, a deleted clone inside the clone folder reads as "outside your + // clone folder", which is a true refusal for a false reason. + try { + return join(await realpath(dirname(p)), basename(p)); + } catch { + return resolve(p); + } + } +} + +/** A directory that is (or contains) a git repo — `.git` may be a dir or a file + * (worktrees/submodules use a gitfile), so a plain existence check is right. */ +async function isRepoDir(root: string): Promise { + try { + await stat(join(root, ".git")); + return true; + } catch { + return false; + } +} + +/** Run `fn` over `items` at most `limit` at a time, preserving order. */ +async function mapLimit(items: T[], limit: number, fn: (t: T) => Promise): Promise { + const out = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = next++; + if (i >= items.length) return; + out[i] = await fn(items[i]); + } + }); + await Promise.all(workers); + return out; +} + +export class LocalRepoScanner { + private cache: { at: number; key: string; value: LocalCopy[] } | undefined; + + /** `now` is injectable so the 30s cache is testable without real waiting. */ + constructor(private readonly now: () => number = () => Date.now()) {} + + /** Drop the cache — call after anything that changes what's on disk. */ + invalidate(): void { + this.cache = undefined; + } + + async scan(input: ScanInput): Promise { + const key = JSON.stringify([input.cloneDir, input.recents, input.current ?? ""]); + const c = this.cache; + if (c && c.key === key && this.now() - c.at < SCAN_TTL_MS) return c.value; + const value = await scanLocalCopies(input); + this.cache = { at: this.now(), key, value }; + return value; + } +} + +/** The uncached scan. Missing paths are REPORTED (dimmed in the UI), never + * silently dropped — a recent whose folder was deleted elsewhere is exactly + * the thing the manager exists to show. */ +export async function scanLocalCopies(input: ScanInput): Promise { + const realCloneDir = await realOrResolve(input.cloneDir); + const realCurrent = input.current ? await realOrResolve(input.current) : undefined; + const managedRoots: string[] = []; + try { + const names = await readdir(input.cloneDir, { withFileTypes: true }); + for (const d of names) { + if (!d.isDirectory() || d.name.startsWith(".")) continue; + const root = join(input.cloneDir, d.name); + if (await isRepoDir(root)) managedRoots.push(root); + if (managedRoots.length >= MAX_ENTRIES) break; + } + } catch { + /* clone dir missing / unreadable — recents still list */ + } + managedRoots.sort((a, b) => basename(a).localeCompare(basename(b))); + + // Recents first (they carry the app's own ordering), then managed folders. + const candidates = [...input.recents, ...managedRoots].slice(0, MAX_ENTRIES); + + // Dedupe by REAL path: a recent entry and a managed folder can be the same + // repo reached through a symlink, and showing it twice with two different + // action sets would be a lie about what's on disk. + const seen = new Map(); + const resolved = await mapLimit(candidates, PROBE_CONCURRENCY, async (root) => { + try { + return { root, real: await realpath(root), missing: false }; + } catch { + return { root, real: resolve(root), missing: true }; + } + }); + + for (const { root, real, missing } of resolved) { + const existing = seen.get(real); + const managed = isInside(realCloneDir, real); + const recent = input.recents.some((r) => resolve(r) === resolve(root)); + if (existing) { + existing.managed ||= managed; + existing.recent ||= recent; + continue; + } + seen.set(real, { + root: missing ? resolve(root) : real, + name: basename(root) || real, + managed, + recent, + missing, + current: !!realCurrent && realCurrent === real, + }); + } + + const list = [...seen.values()]; + const origins = await mapLimit(list, PROBE_CONCURRENCY, (c) => + c.missing ? Promise.resolve(undefined) : originOf(c.root), + ); + list.forEach((c, i) => { + if (origins[i]) c.origin = origins[i]; + }); + + // Present ones first, then by name — a deleted clone shouldn't head the list. + return list.sort((a, b) => { + if (a.missing !== b.missing) return a.missing ? 1 : -1; + return a.name.localeCompare(b.name); + }); +} + +/** The rule, applied to REAL paths — the form main.ts must use, since a + * configured clone dir and a scanned repo root can reach the same place + * through different symlinks. */ +export async function trashRefusalResolved( + root: string, + o: { cloneDir: string; current?: string }, +): Promise { + const real = await realOrResolve(root); + const refusal = trashRefusal(real, { + cloneDir: await realOrResolve(o.cloneDir), + current: o.current ? await realOrResolve(o.current) : undefined, + }); + if (refusal) return refusal; + // The UI only ever offers rows that came from a scan, so this can't be hit + // from the app — but the channel is reachable, and "inside the clone folder" + // must never be enough on its own to delete a folder. + if (!(await isRepoDir(real))) { + return "That folder isn't a git repository — GitStudio won't delete it."; + } + return null; +} + +/** Why this root must not be trashed, or null when it's safe. + * Pure so the rule is testable and stated in exactly one place. */ +export function trashRefusal( + root: string, + o: { cloneDir: string; current?: string }, +): string | null { + const r = resolve(root); + if (!r || r === resolve(o.cloneDir)) { + return "That's the clone folder itself, not a repository inside it."; + } + if (o.current && resolve(o.current) === r) { + return "That repository is open right now — switch to another one first."; + } + if (!isInside(o.cloneDir, r)) { + return "GitStudio only deletes clones inside your clone folder. Remove this one from Finder if you meant to."; + } + return null; +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 346565f..b9e5c33 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -9,6 +9,7 @@ import { app, BrowserWindow, + clipboard, dialog, ipcMain, Menu, @@ -17,7 +18,7 @@ import { } from "electron"; import type { IpcMainInvokeEvent, MenuItemConstructorOptions, WebContents } from "electron"; import { AsyncLocalStorage } from "node:async_hooks"; -import { join } from "node:path"; +import { join, basename, extname } from "node:path"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { RepoStore } from "./repoStore"; import { GitBridge } from "./gitBridge"; @@ -26,21 +27,29 @@ import { RebaseBridge } from "./rebaseBridge"; import { AiBridge } from "./aiBridge"; import { TerminalBridge } from "./terminalBridge"; import { pickCloneDir, startClone, listGhRepos, killActiveClones } from "./cloneBridge"; +import { AppSettings } from "./appSettings"; +import { LocalRepoScanner, samePath, trashRefusalResolved } from "./localRepos"; +import { openGitHubRepo, managedReposDir } from "./ghRepoOpen"; import { initAutoUpdate } from "./autoUpdate"; +import type { UpdateManager } from "./autoUpdate"; import { ErrorReporter } from "./errorReporter"; import { isExpectedError } from "./expectedError"; import { RepoWatcher } from "./repoWatcher"; import * as issuesApi from "./github/issues"; +import * as myWorkApi from "./github/myWork"; import * as prsApi from "./github/prs"; import * as actionsApi from "./github/actions"; import * as releasesApi from "./github/releases"; import * as notificationsApi from "./github/notifications"; import * as orgsApi from "./github/orgs"; +import * as repoBrowseApi from "./github/repoBrowse"; +import * as searchApi from "./github/search"; import * as projectsApi from "./github/projects"; import * as gistsApi from "./github/gists"; import type { CommitActionResult, IpcChannel, + LocalCopy, IpcEvents, IpcRequest, IpcResponse, @@ -53,7 +62,11 @@ import type { app.setName("GitStudio"); let mainWindow: BrowserWindow | undefined; +/** The poll→confirm→pull update manager; set once at startup. */ +let updates: UpdateManager | undefined; let repos: RepoStore; +let appSettings: AppSettings; +const localRepos = new LocalRepoScanner(); let bridge: GitBridge; let github: GitHubBridge; let rebase: RebaseBridge; @@ -129,8 +142,12 @@ function hardenWebContents(contents: WebContents): void { } }); contents.on("will-attach-webview", (event) => event.preventDefault()); - contents.session.setPermissionRequestHandler((_wc, _permission, callback) => - callback(false), + // Deny every permission request EXCEPT plain clipboard writes — the blanket + // deny made navigator.clipboard.writeText reject, so every Copy button + // (including the GitHub device-flow confirmation code) errored instead of + // copying. Writes only; clipboard READS stay denied. + contents.session.setPermissionRequestHandler((_wc, permission, callback) => + callback(permission === "clipboard-sanitized-write"), ); } @@ -412,6 +429,7 @@ function actionLabel(channel: string): string { "sync:push": "Push", "branch:push": "Push branch", "branches:list": "List branches", + "ref:log": "Read ref history", "branch:create": "Create branch", "branch:delete": "Delete branch", "branch:pullFf": "Pull branch", @@ -421,6 +439,10 @@ function actionLabel(channel: string): string { "repo:file": "Read file", "repo:open": "Open repository", "repo:openPath": "Open repository", + "repos:local": "List local repositories", + "repos:reveal": "Reveal repository", + "repos:removeRecent": "Forget repository", + "repos:trash": "Delete clone", "clone:start": "Clone", "pr:checkout": "Checkout PR", "git:identity": "Read identity", @@ -473,6 +495,53 @@ function registerIpc(): void { handle("repo:open", () => openRepoDialog()); handle("repo:openPath", (path) => openRepoPath(path)); handle("repo:recent", async () => repos.recentRepos()); + handle("search:repos", (req) => github.withClient((c) => searchApi.searchRepos(c, req))); + handle("search:users", (req) => github.withClient((c) => searchApi.searchUsers(c, req))); + handle("search:code", (req) => github.withClient((c) => searchApi.searchCode(c, req))); + handle("repos:local", () => scanLocalCopies()); + handle("repos:reveal", async (root) => { + // Only reveal something the app already lists. `showItemInFolder` on an + // arbitrary renderer-supplied string is the one shell call here with no + // natural bound, and every real caller passes a row from this same scan. + const known = (await scanLocalCopies()).some((c) => samePath(c.root, root)); + if (!known) return false; + shell.showItemInFolder(root); + return true; + }); + handle("repos:removeRecent", async (root) => { + if (repos.removeRecent(root)) { + void saveState(); + localRepos.invalidate(); + buildMenu(); // the Recent Repositories submenu is built from this list + send("repo:recentChanged", repos.recentRepos()); + } + return scanLocalCopies(); + }); + handle("repos:trash", async (root) => { + // Deleting someone's working copy is the most destructive thing this app + // can do, so the rule lives in ONE pure function and is enforced HERE — + // never in the renderer, which can't be trusted to be the only caller. + const refusal = await trashRefusalResolved(root, { + cloneDir: appSettings.effectiveCloneDir(), + current: repos.current()?.root, + }); + if (refusal) return { ok: false, changed: false, expected: true, message: refusal }; + try { + await shell.trashItem(root); + } catch (e) { + return { + ok: false, + changed: false, + expected: true, + message: e instanceof Error ? e.message : "Couldn't move that folder to the trash.", + }; + } + if (repos.removeRecent(root)) void saveState(); + localRepos.invalidate(); + buildMenu(); + send("repo:recentChanged", repos.recentRepos()); + return { ok: true, changed: true }; + }); handle("repo:current", async () => repos.current()); handle("repo:close", async () => { closeRepo(); @@ -530,6 +599,7 @@ function registerIpc(): void { // Branch management. handle("branches:list", () => bridge.branchesList()); + handle("ref:log", (req) => bridge.refLog(req)); handle("branch:create", (req) => bridge.branchCreate(req)); handle("branch:delete", (req) => bridge.branchDelete(req)); handle("branch:pullFf", (req) => bridge.branchPullFf(req.name)); @@ -552,7 +622,7 @@ function registerIpc(): void { handle("terminal:kill", async (req) => terminal.kill(req.id)); // Clone / browse repos. - handle("clone:pickDir", () => pickCloneDir()); + handle("clone:pickDir", (req) => pickCloneDir(req?.defaultPath ?? appSettings.effectiveCloneDir())); handle("clone:start", (req) => startClone(req, (p) => send("clone:progress", p))); handle("github:repos", (req) => github.withClient((c) => listGhRepos(c, req?.search)), @@ -568,6 +638,34 @@ function registerIpc(): void { // Settings: git identity + local SSH keys. handle("git:identity", () => bridge.gitIdentity()); handle("git:setIdentity", (req) => bridge.setGitIdentity(req)); + handle("clipboard:write", async (text) => { + clipboard.writeText(typeof text === "string" ? text : String(text ?? "")); + }); + + // App info + updates (poll → confirm → pull → apply). + handle("app:info", async () => ({ version: app.getVersion(), platform: process.platform })); + handle("settings:get", () => Promise.resolve(appSettings.view())); + handle("settings:update", (patch) => appSettings.update(patch)); + handle("settings:pickCloneDir", async () => { + const r = await dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"], + title: "Choose the default clone folder", + defaultPath: appSettings.effectiveCloneDir(), + }); + if (r.canceled || !r.filePaths[0]) return undefined; + return appSettings.update({ cloneDir: r.filePaths[0] }); + }); + handle("update:check", async () => + updates + ? updates.check(true) + : { status: "disabled" as const, current: app.getVersion(), message: "Updater not ready." }, + ); + handle("update:download", async () => + updates ? updates.download() : { ok: false, message: "Updater not ready." }, + ); + handle("update:install", async () => + updates ? updates.install() : { ok: false, message: "Updater not ready." }, + ); handle("ssh:keys", () => bridge.sshKeys()); handle("pr:list", () => github.prList()); handle("pr:detail", (n) => github.prDetail(n)); @@ -577,7 +675,7 @@ function registerIpc(): void { handle("pr:conversation", (n) => github.prConversation(n)); handle("pr:checks", (n) => github.prChecks(n)); handle("pr:approve", (n) => github.prApprove(n)); - handle("actions:runs", () => github.actionsRuns()); + handle("actions:runs", (req) => github.withRepo((c, o, r) => actionsApi.listRuns(c, o, r, req))); handle("issue:list", (req) => github.withRepo((c, o, r) => issuesApi.listIssues(c, o, r, req?.state ?? "open"))); // ── Section modules: full CRUD for issues / PRs / actions / releases / @@ -586,6 +684,7 @@ function registerIpc(): void { handle("issue:detail", (n) => github.withRepo((c, o, r) => issuesApi.getIssueDetail(c, o, r, n))); // Cross-repo read-only item view (notifications for OTHER repos open in-app). handle("github:externalItem", (req) => github.externalItem(req)); + handle("github:myWork", () => github.withRepo((c, o, r) => myWorkApi.myWork(c, o, r))); handle("issue:create", (req) => github.withRepo((c, o, r) => issuesApi.createIssue(c, o, r, req))); handle("issue:comment", (req) => github.withRepo((c, o, r) => issuesApi.commentIssue(c, o, r, req))); handle("issue:setState", (req) => github.withRepo((c, o, r) => issuesApi.setIssueState(c, o, r, req))); @@ -617,6 +716,30 @@ function registerIpc(): void { handle("release:create", (input) => github.withRepo((c, o, r) => releasesApi.createRelease(c, o, r, input))); handle("release:update", (input) => github.withRepo((c, o, r) => releasesApi.updateRelease(c, o, r, input))); handle("release:delete", (id) => github.withRepo((c, o, r) => releasesApi.deleteRelease(c, o, r, id))); + handle("release:uploadAssets", async (req) => { + // The file dialog lives HERE (main) — the renderer has no filesystem. + const picked = await dialog.showOpenDialog({ + title: "Attach assets to the release", + buttonLabel: "Upload", + properties: ["openFile", "multiSelections"], + }); + if (picked.canceled || picked.filePaths.length === 0) { + return { ok: false, changed: false, message: "No files selected.", expected: true }; + } + return github.withRepo(async (c, o, r) => { + for (const fp of picked.filePaths) { + const name = basename(fp); + const data = await readFile(fp); + const res = await releasesApi.uploadAssetData(c, o, r, req.id, name, data, assetContentType(name)); + if (!res.ok) { + return { ...res, message: `${name}: ${res.message ?? "upload failed"}` }; + } + } + const n = picked.filePaths.length; + return { ok: true, changed: false, message: `Uploaded ${n} asset${n === 1 ? "" : "s"}.` }; + }); + }); + handle("release:deleteAsset", (id) => github.withRepo((c, o, r) => releasesApi.deleteAsset(c, o, r, id))); // Notifications (user-level). handle("notifications:list", (opts) => github.withClient((c) => notificationsApi.listNotifications(c, opts))); // Ambient: polls on launch, so it must NOT unlock the token (that prompted for @@ -634,6 +757,40 @@ function registerIpc(): void { handle("orgs:repos", (org) => github.withClient((c) => orgsApi.listOrgRepos(c, org))); handle("orgs:teams", (org) => github.withClient((c) => orgsApi.listOrgTeams(c, org))); handle("orgs:members", (org) => github.withClient((c) => orgsApi.listOrgMembers(c, org))); + handle("orgs:repoDetail", (fullName) => github.withClient((c) => orgsApi.getOrgRepoDetail(c, fullName))); + handle("ghrepo:open", (req) => + openGitHubRepo( + req.fullName, + repos, + (p) => send("clone:progress", p), + appSettings.effectiveCloneDir(), + req.dest, + req.name, + ), + ); + handle("ghrepo:tree", (req) => + github.withClient((c) => repoBrowseApi.listRepoDir(c, req.fullName, req.path, req.ref)), + ); + handle("ghrepo:file", (req) => + github.withClient((c) => repoBrowseApi.readRepoFile(c, req.fullName, req.path, req.ref)), + ); + handle("ghrepo:readme", (req) => + github.withClient((c) => + typeof req === "string" + ? repoBrowseApi.readRepoReadme(c, req) + : repoBrowseApi.readRepoReadme(c, req.fullName, req.ref), + ), + ); + handle("ghrepo:branches", (fullName) => + github.withClient((c) => repoBrowseApi.listRepoBranches(c, fullName)), + ); + handle("ghrepo:paths", (req) => + github.withClient((c) => repoBrowseApi.listRepoPaths(c, req.fullName, req.ref)), + ); + handle("orgs:teamMembers", (req) => github.withClient((c) => orgsApi.listTeamMembers(c, req.org, req.slug))); + handle("github:userInfo", (login) => github.withClient((c) => orgsApi.getUserInfo(c, login))); + handle("users:repos", (login) => github.withClient((c) => orgsApi.listUserRepos(c, login))); + handle("users:orgs", (login) => github.withClient((c) => orgsApi.listUserOrgs(c, login))); // Projects v2. handle("project:list", () => github.withRepo((c, o, r) => projectsApi.listProjects(c, o, r))); handle("project:board", (id) => github.withRepo((c, o, r) => projectsApi.getProjectBoard(c, o, r, id))); @@ -718,7 +875,8 @@ function registerIpc(): void { handle("label:update", (req) => github.withRepo((c, o, r) => issuesApi.updateLabel(c, o, r, req))); handle("label:delete", (name) => github.withRepo((c, o, r) => issuesApi.deleteLabel(c, o, r, name))); handle("actions:jobLog", (req) => github.withRepo((c, o, r) => actionsApi.jobLog(c, o, r, req))); - handle("actions:runLog", (req) => github.withRepo((c, o, r) => actionsApi.runLog(c, o, r, req))); + handle("actions:jobLogChunk", (req) => github.withRepo((c, o, r) => actionsApi.jobLogChunk(c, o, r, req))); + handle("actions:saveLog", (req) => github.withRepo((c, o, r) => actionsApi.saveLog(c, o, r, req))); handle("actions:artifacts", (id) => github.withRepo((c, o, r) => actionsApi.artifacts(c, o, r, id))); handle("actions:downloadArtifact", (req) => github.withRepo((c, o, r) => actionsApi.downloadArtifact(c, o, r, req))); handle("actions:secrets", () => github.withRepo((c, o, r) => actionsApi.secrets(c, o, r))); @@ -768,6 +926,10 @@ async function boot(): Promise { const state = await loadState(); repos = new RepoStore(state.recent); + appSettings = await AppSettings.load(app.getPath("userData"), { + defaultCloneDir: managedReposDir(), + home: app.getPath("home"), + }); bridge = new GitBridge(repos); github = new GitHubBridge(repos); rebase = new RebaseBridge(repos); @@ -809,7 +971,7 @@ async function boot(): Promise { // tile before the renderer reports its (possibly overridden) theme. setDockIcon(nativeTheme.shouldUseDarkColors ? "dark" : "light"); await createWindow(); - initAutoUpdate({ isDev: !app.isPackaged }); + updates = initAutoUpdate({ isDev: !app.isPackaged, send }); // Re-open the last repo, if any, so the window lands on real history. if (state.current) { @@ -854,3 +1016,25 @@ app.on("before-quit", () => { ai?.dispose(); repos?.dispose(); }); + +/** MIME type for a release-asset upload, from the file extension. GitHub only + * uses it for the download response's Content-Type — octet-stream is fine. */ +function assetContentType(name: string): string { + switch (extname(name).toLowerCase()) { + case ".dmg": return "application/x-apple-diskimage"; + case ".zip": case ".vsix": case ".nupkg": return "application/zip"; + case ".gz": case ".tgz": return "application/gzip"; + case ".txt": case ".md": return "text/plain"; + case ".json": return "application/json"; + default: return "application/octet-stream"; + } +} + +/** The Settings → Repositories manager's list: the clone folder ∪ recents. */ +function scanLocalCopies(): Promise { + return localRepos.scan({ + cloneDir: appSettings.effectiveCloneDir(), + recents: repos.recentRepos().map((r) => r.root), + current: repos.current()?.root, + }); +} diff --git a/apps/desktop/src/main/repoStore.ts b/apps/desktop/src/main/repoStore.ts index cdffe38..6f78f21 100644 --- a/apps/desktop/src/main/repoStore.ts +++ b/apps/desktop/src/main/repoStore.ts @@ -4,7 +4,7 @@ // Electron-specific beyond the persistence path, so the data layer stays the // same one the extension uses. -import { basename } from "node:path"; +import { basename, resolve } from "node:path"; import { GitContext, NodeGitAdapter } from "@gitstudio/git-service/index"; import type { GitRunHook } from "@gitstudio/git-service/index"; import type { RepoInfo } from "../shared/ipc"; @@ -102,6 +102,18 @@ export class RepoStore { this.listeners.clear(); } + /** Forget a root (Settings → Repositories). Returns true when it was there — + * main.ts persists only on a real change. Never touches disk or the open + * repo: forgetting a repo you're standing in is a list edit, nothing more. */ + removeRecent(root: string): boolean { + const before = this.recent.length; + // Compare RESOLVED paths: the manager hands back realpath'd roots, and a + // recent stored through a symlink would otherwise never match — the click + // would report success and change nothing. + this.recent = this.recent.filter((r) => resolve(r) !== resolve(root)); + return this.recent.length !== before; + } + private promoteRecent(root: string): void { this.recent = [root, ...this.recent.filter((r) => r !== root)].slice( 0, diff --git a/apps/desktop/src/renderer/assistant.ts b/apps/desktop/src/renderer/assistant.ts index ab210bc..8d75df9 100644 --- a/apps/desktop/src/renderer/assistant.ts +++ b/apps/desktop/src/renderer/assistant.ts @@ -14,6 +14,14 @@ import { runAgentTurn, addBubble, markdownBlock, errorBlock, connectPrompt, elTe import type { SectionRender } from "./views/common"; import type { AiModelOption, AiSettingsView, ChatView } from "../shared/ipc"; +/** A goal handed in from elsewhere (✨ actions in PR/issue views) — consumed + * by the next render. The ✨ flow used to open a CHAT TAB in the bottom dock, + * which split the screen in half; now it lands here, in the one AI surface. */ +let pendingGoal: string | null = null; +export function seedAssistantGoal(goal: string): void { + pendingGoal = goal; +} + /** Agent write permission, remembered across navigations within a session. */ let permission: "read" | "write" | "destructive" = "read"; /** The explicit model id the user picked (from the provider's models). */ @@ -295,4 +303,12 @@ export const renderAssistant: SectionRender = (wrap, nav) => { void runGoal(input.value); } }); + + // A goal seeded from another view (✨ Explain / Review / …) starts running + // the moment this surface is up. + if (pendingGoal) { + const goal = pendingGoal; + pendingGoal = null; + void runGoal(goal); + } }; diff --git a/apps/desktop/src/renderer/chatRender.ts b/apps/desktop/src/renderer/chatRender.ts index bfe6e4c..f152c35 100644 --- a/apps/desktop/src/renderer/chatRender.ts +++ b/apps/desktop/src/renderer/chatRender.ts @@ -10,6 +10,7 @@ import { host } from "./bridge"; import { el, span, glyph } from "./ui"; import { renderMarkdown } from "./markdown"; +import { highlightProse } from "./highlight"; import { confirmDialog, toast } from "./dialogs"; import type { AgentConfirmRequest, AgentEventWire } from "../shared/ipc"; @@ -128,7 +129,7 @@ export async function runAgentTurn( /** Append a streamed text delta and re-render the block as Markdown (live). */ export function onDelta(state: TurnState, delta: string): void { if (!state.stream) { - state.stream = el("div", "assistant-msg is-streaming"); + state.stream = el("div", "assistant-msg gh-body-md is-streaming"); state.turn.insertBefore(state.stream, state.thinking); state.raw = ""; } @@ -222,8 +223,9 @@ export function addBubble(transcript: HTMLElement, who: "user", text: string): v } export function markdownBlock(md: string): HTMLElement { - const block = el("div", "assistant-msg"); + const block = el("div", "assistant-msg gh-body-md"); block.innerHTML = renderMarkdown(md); + highlightProse(block); return block; } diff --git a/apps/desktop/src/renderer/cloneDialog.ts b/apps/desktop/src/renderer/cloneDialog.ts index dc0188e..29e8c92 100644 --- a/apps/desktop/src/renderer/cloneDialog.ts +++ b/apps/desktop/src/renderer/cloneDialog.ts @@ -13,7 +13,7 @@ // `modal()` in ./dialogs (which isn't exported), so this self-contained module // matches that a11y behaviour exactly. -import { toast } from "./dialogs"; +import { toast, openModal } from "./dialogs"; import { host } from "./bridge"; import { el, @@ -25,56 +25,22 @@ import { cleanErr, } from "./ui"; import type { GhRepoBrief } from "../shared/ipc"; +import { deriveNameFromUrl, validateTargetName } from "../shared/cloneName"; type Tab = "url" | "github"; type Scheme = "https" | "ssh"; -/** Open the clone modal. On a successful clone, `onCloned(root)` is called. */ -export function openCloneDialog(onCloned: (root: string) => void): void { - // ── modal scaffold (mirrors ./dialogs modal(): focus-trap, Esc, backdrop) ── - const prevFocus = document.activeElement as HTMLElement | null; - const overlay = el("div", "modal-overlay"); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", "Clone a repository"); - +/** Open the clone modal. On a successful clone, `onCloned(root)` is called. + * `opts.url` prefills the URL tab (e.g. cloning straight from an org's repo + * peek) — the user still picks the destination folder. */ +export function openCloneDialog( + onCloned: (root: string) => void, + opts: { url?: string } = {}, +): void { + // ── modal scaffold: the shared openModal (focus-trap, Esc, backdrop) ────── const card = el("div", "modal-card clone-card"); - - let closed = false; - const close = (): void => { - if (closed) return; - closed = true; - if (offProgress) offProgress(); - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - prevFocus?.focus?.(); - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - // While a clone is in flight, dismissing would orphan the clone and still - // fire onCloned() on completion — match the busy-guarded backdrop click. - if (busy) return; - e.preventDefault(); - close(); - return; - } - if (e.key !== "Tab") return; - const f = Array.from( - card.querySelectorAll( - "button, input, [tabindex]:not([tabindex='-1'])", - ), - ).filter((n) => !n.hasAttribute("disabled") && n.offsetParent !== null); - if (!f.length) return; - const first = f[0]; - const last = f[f.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }; + /** Bound by openModal at mount; wrapped so earlier listeners see the real one. */ + let close = (): void => {}; // ── state ──────────────────────────────────────────────────────────────── let tab: Tab = "url"; @@ -161,6 +127,24 @@ export function openCloneDialog(onCloned: (root: string) => void): void { destText.append(destLabel, destValue); destRow.append(destText, chooseBtn); + // Folder-name override — blank means "use the name derived from the URL" + // (shown as the placeholder, so what will happen is never a mystery). + const nameRow = el("div", "clone-dest clone-name-row"); + const nameText = el("div", "clone-dest-text"); + const nameLabel = el("div", "clone-dest-label"); + nameLabel.textContent = "Folder name"; + const nameInput = document.createElement("input"); + nameInput.className = "modal-input clone-name-input"; + nameInput.placeholder = "Derived from the URL"; + nameInput.spellcheck = false; + nameInput.autocapitalize = "off"; + nameInput.setAttribute("aria-label", "Folder name (optional)"); + nameInput.addEventListener("input", refreshClone); + nameText.append(nameLabel, nameInput); + nameRow.append(nameText); + const nameError = el("div", "dest-name-error clone-name-error"); + nameError.hidden = true; + const progress = el("div", "clone-progress"); progress.hidden = true; const progBar = el("div", "clone-progress-bar"); @@ -174,7 +158,7 @@ export function openCloneDialog(onCloned: (root: string) => void): void { const actions = el("div", "modal-actions clone-actions"); const cancel = el("button", "mini-btn"); cancel.textContent = "Cancel"; - cancel.addEventListener("click", close); + cancel.addEventListener("click", () => close()); const primary = el("button", "btn btn-primary modal-ok clone-go"); const primaryLabel = span("Clone"); primary.append(primaryLabel); @@ -182,8 +166,7 @@ export function openCloneDialog(onCloned: (root: string) => void): void { primary.addEventListener("click", () => void runClone()); actions.append(cancel, primary); - card.append(h, tabs, urlPanel, ghPanel, destRow, progress, actions); - overlay.appendChild(card); + card.append(h, tabs, urlPanel, ghPanel, destRow, nameRow, nameError, progress, actions); // ── tab switching ────────────────────────────────────────────────────────── function setTab(next: Tab): void { @@ -334,16 +317,15 @@ export function openCloneDialog(onCloned: (root: string) => void): void { return scheme === "ssh" ? selectedRepo.sshUrl : selectedRepo.cloneUrl; } - /** Derive the target folder name from the URL (so it's stable + predictable). */ + /** The folder name a clone would use: the override, else derived. */ function targetName(url: string): string | undefined { - const m = url.match(/([^/:]+?)(?:\.git)?\/?\s*$/); - return m ? m[1] : undefined; + return nameInput.value.trim() || deriveNameFromUrl(url); } async function pickDir(): Promise { if (busy) return; try { - const dir = await host.invoke("clone:pickDir", undefined); + const dir = await host.invoke("clone:pickDir", parentDir ? { defaultPath: parentDir } : undefined); if (dir) { parentDir = dir; destValue.textContent = dir; @@ -356,7 +338,13 @@ export function openCloneDialog(onCloned: (root: string) => void): void { } function refreshClone(): void { - const ready = !busy && !!chosenUrl() && !!parentDir; + const url = chosenUrl(); + nameInput.placeholder = (url && deriveNameFromUrl(url)) || "Derived from the URL"; + const problem = validateTargetName(nameInput.value); + nameError.textContent = problem ?? ""; + nameError.hidden = !problem; + nameInput.classList.toggle("is-invalid", !!problem); + const ready = !busy && !!url && !!parentDir && !problem; if (ready) primary.removeAttribute("disabled"); else primary.setAttribute("disabled", "true"); } @@ -385,6 +373,7 @@ export function openCloneDialog(onCloned: (root: string) => void): void { httpsBtn, sshBtn, chooseBtn, + nameInput, cancel, ]) { if (on) ctl.setAttribute("disabled", "true"); @@ -437,16 +426,45 @@ export function openCloneDialog(onCloned: (root: string) => void): void { toast(res.message || "Clone failed.", "error"); progress.hidden = true; setBusy(false); + // A destination collision is fixed right here: point at the name field. + if (res.code === "dest-exists" || res.code === "bad-name") { + nameInput.focus(); + nameInput.select(); + } } } // ── mount ────────────────────────────────────────────────────────────────── - document.body.appendChild(overlay); - overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay && !busy) close(); + openModal((c) => { + close = c; + return { + card, + focusEl: urlInput, + label: "Clone a repository", + // While a clone is in flight, dismissing (Esc/backdrop) would orphan the + // clone and still fire onCloned() on completion — keep the modal up. + canDismiss: () => !busy, + onClose: () => { + if (offProgress) offProgress(); + }, + }; }); - document.addEventListener("keydown", onKey, true); + if (opts.url) urlInput.value = opts.url; setTab("url"); + + // Prefill the configured default clone folder so Clone is one paste away — + // Choose… still overrides per-clone. + void host + .invoke("settings:get", undefined) + .then((v) => { + if (!parentDir) { + parentDir = v.cloneDir; + destValue.textContent = v.cloneDirDisplay; + destValue.title = v.cloneDir; + refreshClone(); + } + }) + .catch(() => {}); } /** A tiny inline badge appended to a repo's name (Private / Fork). */ diff --git a/apps/desktop/src/renderer/commandPalette.ts b/apps/desktop/src/renderer/commandPalette.ts new file mode 100644 index 0000000..d982fda --- /dev/null +++ b/apps/desktop/src/renderer/commandPalette.ts @@ -0,0 +1,261 @@ +// The ⌘K command palette — the Linear signature, and the piece that makes +// every corner of the app one keystroke away: sections, branches/tags, +// recent repositories, open PRs and issues, and every headline action +// (new branch, stash, fetch/pull/push, clone, theme…) live in ONE fuzzy +// search. Local groups render instantly; GitHub groups stream in as they +// resolve, so the palette never waits on the network to be useful. +// +// Self-contained overlay (same contract as peeks/modals: Esc, backdrop, +// focus). The App supplies data + actions through PaletteProviders. + +import { el, span, glyph } from "./ui"; +import { createSearchScheduler } from "./searchDebounce"; + +export interface PaletteItem { + /** Codicon for the row. */ + icon: string; + label: string; + /** Muted right-side hint ("view", a branch's subject, "#42 · open"…). */ + hint?: string; + /** Extra text the fuzzy matcher may hit (number, author, sha…). */ + keywords?: string; + run: () => void; +} + +export interface PaletteGroup { + title: string; + items: PaletteItem[]; + /** Skip fuzzy filtering for this group. A search group's items ARE the + * answer to the query — re-filtering them by the same query throws away + * results GitHub already ranked (and drops the "Search GitHub for…" row + * the moment the query stops matching its own label). */ + pinned?: boolean; +} + +export interface PaletteProviders { + /** Instant, local groups (views, actions, branches, recent repos). */ + local: () => PaletteGroup[]; + /** Slow groups (PRs, issues) — appended when they resolve. */ + remote: () => Array>; + /** QUERY-driven groups (global GitHub search). Unlike `remote`, this fires + * as the user types — debounced, minimum-length-gated, and generation- + * checked by the palette so a slow answer to an old query is dropped. */ + search?: (query: string) => Array>; +} + +let live: { overlay: HTMLElement; dispose: () => void } | null = null; + +export function paletteIsOpen(): boolean { + return live !== null; +} + +export function closeCommandPalette(): void { + live?.dispose(); +} + +/** + * Subsequence fuzzy score: every query char must appear in order. Earlier, + * denser, word-start matches score higher; 0 = no match. + * + * Exported because Explore's go-to-file wants EXACTLY this ranking — a second + * fuzzy matcher would drift from the palette's feel for no reason. The "/" + * word-boundary bonus already suits paths. + */ +export function fuzzyScore(query: string, text: string): number { + if (!query) return 1; + const q = query.toLowerCase(); + const t = text.toLowerCase(); + let qi = 0; + let score = 0; + let streak = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + qi++; + streak++; + score += 2 + streak; // consecutive hits compound + if (ti === 0 || t[ti - 1] === " " || t[ti - 1] === "/" || t[ti - 1] === "-") score += 4; + } else { + streak = 0; + } + } + if (qi < q.length) return 0; + return score + Math.max(0, 24 - t.length / 4); // shorter targets edge ahead +} + +export function openCommandPalette(providers: PaletteProviders): void { + closeCommandPalette(); + const prevFocus = document.activeElement as HTMLElement | null; + + const overlay = el("div", "cmdk-overlay"); + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-label", "Command palette"); + const card = el("div", "cmdk-card"); + const inputRow = el("div", "cmdk-input-row"); + const icon = glyph("search"); + const input = document.createElement("input"); + input.className = "cmdk-input"; + input.placeholder = "Jump to a section, branch, PR, repo — or run an action…"; + input.setAttribute("aria-label", "Search commands"); + const kbd = el("span", "cmdk-esc"); + kbd.textContent = "esc"; + inputRow.append(icon, input, kbd); + const list = el("div", "cmdk-list"); + list.setAttribute("role", "listbox"); + card.append(inputRow, list); + overlay.appendChild(card); + + let groups: PaletteGroup[] = providers.local(); + /** Query-driven groups, replaced wholesale per search generation. */ + let searchGroups: PaletteGroup[] = []; + let flat: Array<{ item: PaletteItem; el: HTMLElement }> = []; + let selected = 0; + + const dispose = (): void => { + if (live?.overlay !== overlay) return; + live = null; + scheduler?.cancel(); + document.body.classList.remove("cmdk-open"); + overlay.remove(); + document.removeEventListener("keydown", onKey, true); + prevFocus?.focus?.(); + }; + + const select = (i: number): void => { + if (!flat.length) return; + selected = Math.max(0, Math.min(i, flat.length - 1)); + flat.forEach(({ el: row }, idx) => row.classList.toggle("is-selected", idx === selected)); + flat[selected]?.el.scrollIntoView({ block: "nearest" }); + }; + + const activate = (i: number): void => { + const hit = flat[i]; + if (!hit) return; + dispose(); + hit.item.run(); + }; + + const render = (): void => { + const q = input.value.trim(); + // A streamed group (PRs/issues) landing mid-navigation must not snap the + // highlight back to the top — re-select the same ITEM after rebuilding. + const keep = flat[selected]?.item; + list.replaceChildren(); + flat = []; + for (const group of [...searchGroups, ...groups]) { + // A pinned group is already the answer to this query — render it as-is. + const scored = group.pinned + ? group.items.map((item) => ({ item, score: 1 })) + : group.items + .map((item) => ({ + item, + score: Math.max( + fuzzyScore(q, item.label), + item.keywords ? fuzzyScore(q, item.keywords) * 0.9 : 0, + ), + })) + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, q ? 8 : 6); + if (!scored.length) continue; + const head = el("div", "cmdk-group"); + head.textContent = group.title; + list.appendChild(head); + for (const { item } of scored) { + const row = el("div", "cmdk-row"); + row.setAttribute("role", "option"); + row.append(glyph(item.icon), span(item.label, "cmdk-label")); + if (item.hint) row.appendChild(span(item.hint, "cmdk-hint")); + const idx = flat.length; + row.addEventListener("mousemove", () => select(idx)); + row.addEventListener("click", () => activate(idx)); + list.appendChild(row); + flat.push({ item, el: row }); + } + } + if (!flat.length) { + const none = el("div", "cmdk-empty"); + none.textContent = q ? `Nothing matches “${q}”.` : "Nothing here yet."; + list.appendChild(none); + } + const kept = keep ? flat.findIndex((f) => f.item === keep) : -1; + select(kept >= 0 ? kept : 0); + }; + + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + dispose(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + select(selected + 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + select(selected - 1); + } else if (e.key === "Enter") { + e.preventDefault(); + activate(selected); + } + }; + + // ── query-driven search mode ── + const scheduler = providers.search + ? createSearchScheduler((query, generation) => { + const provider = providers.search; + if (!provider) return; + // A new generation replaces the previous answers immediately, so the + // list never mixes results from two different queries. + searchGroups = []; + for (const p of provider(query)) { + void p + .then((group) => { + // Three guards, all load-bearing: the palette is still open, + // this is still the newest query, and the group has content. + if (!group || live?.overlay !== overlay) return; + if (!scheduler?.isCurrent(generation)) return; + searchGroups = [...searchGroups, group]; + render(); + }) + .catch(() => { + /* a failed search group simply doesn't appear */ + }); + } + render(); + }) + : undefined; + + input.addEventListener("input", () => { + if (scheduler) { + const q = input.value.trim(); + scheduler.queue(q); + // Clear stale results the moment the query changes — showing the last + // query's hits under a different query is worse than showing none. + if (q !== scheduler.lastQuery()) searchGroups = []; + } + render(); + }); + overlay.addEventListener("mousedown", (e) => { + if (e.target === overlay) dispose(); + }); + document.addEventListener("keydown", onKey, true); + document.body.appendChild(overlay); + // Marks the palette open for OTHER document-level Esc handlers (peeks, + // modals): they stand down so one Esc never closes two layers. + document.body.classList.add("cmdk-open"); + live = { overlay, dispose }; + render(); + input.focus(); + + // Stream the slow groups in (PRs, issues) — appended once, re-rendered + // through the same filter so an in-progress query applies to them too. + for (const p of providers.remote()) { + void p + .then((group) => { + if (!group || live?.overlay !== overlay) return; + groups = [...groups, group]; + render(); + }) + .catch(() => {}); + } +} diff --git a/apps/desktop/src/renderer/destinationSheet.ts b/apps/desktop/src/renderer/destinationSheet.ts new file mode 100644 index 0000000..e439fcd --- /dev/null +++ b/apps/desktop/src/renderer/destinationSheet.ts @@ -0,0 +1,130 @@ +// The "Choose location…" sheet — a small modal that asks WHERE a one-click +// clone should land before it starts. Used by every "open in GitStudio" +// surface when the user asks for control (or always, when the +// ask-where-every-time setting is on), and offered as the retry path after a +// destination collision. Confirms with `{dest, name}`; never clones itself — +// the caller owns the actual ghrepo:open. + +import { host } from "./bridge"; +import { openModal, toast } from "./dialogs"; +import { el, span, glyph, cleanErr } from "./ui"; +import { validateTargetName } from "../shared/cloneName"; + +export function openDestinationSheet( + fullName: string, + onConfirm: (choice: { dest: string; name?: string }) => void, + opts: { + /** Prefill the folder-name field (e.g. retrying after a collision). */ + name?: string; + /** One-line context above the fields (e.g. why the sheet appeared). */ + note?: string; + } = {}, +): void { + const repo = fullName.split("/")[1] ?? fullName; + const card = el("div", "modal-card dest-card"); + let close = (): void => {}; + let dest = ""; + + const title = el("div", "modal-title"); + title.textContent = `Where should ${fullName} go?`; + + const sub = el("div", "modal-message"); + sub.textContent = opts.note ?? "Pick the folder this repository is cloned into."; + + // Destination row — same look as the clone dialog's. + const destRow = el("div", "clone-dest"); + const destText = el("div", "clone-dest-text"); + const destLabel = el("div", "clone-dest-label"); + destLabel.textContent = "Destination"; + const destValue = el("div", "clone-dest-path"); + destValue.textContent = "Loading…"; + destText.append(destLabel, destValue); + const chooseBtn = el("button", "mini-btn"); + chooseBtn.append(glyph("folder-opened"), span("Choose…")); + destRow.append(destText, chooseBtn); + + // Folder-name override. + const nameLabel = el("div", "clone-dest-label dest-name-label"); + nameLabel.textContent = "Folder name"; + const nameInput = document.createElement("input"); + nameInput.className = "modal-input dest-name-input"; + nameInput.placeholder = repo; + nameInput.value = opts.name ?? ""; + nameInput.spellcheck = false; + nameInput.autocapitalize = "off"; + nameInput.setAttribute("aria-label", "Folder name"); + const nameError = el("div", "dest-name-error"); + nameError.hidden = true; + + const actions = el("div", "modal-actions"); + const cancel = el("button", "mini-btn"); + cancel.textContent = "Cancel"; + cancel.addEventListener("click", () => close()); + const go = el("button", "btn btn-primary modal-ok"); + go.textContent = "Clone here"; + go.setAttribute("disabled", "true"); + actions.append(cancel, go); + + card.append(title, sub, destRow, nameLabel, nameInput, nameError, actions); + + function refresh(): void { + const problem = validateTargetName(nameInput.value); + nameError.textContent = problem ?? ""; + nameError.hidden = !problem; + nameInput.classList.toggle("is-invalid", !!problem); + if (dest && !problem) go.removeAttribute("disabled"); + else go.setAttribute("disabled", "true"); + } + nameInput.addEventListener("input", refresh); + + chooseBtn.addEventListener("click", () => { + void (async () => { + try { + const dir = await host.invoke("clone:pickDir", dest ? { defaultPath: dest } : undefined); + if (dir) { + dest = dir; + destValue.textContent = dir; + destValue.title = dir; + refresh(); + } + } catch (e) { + toast(cleanErr(e) || "Couldn't choose a folder.", "error"); + } + })(); + }); + + const confirm = (): void => { + if (go.hasAttribute("disabled")) return; + const name = nameInput.value.trim(); + close(); + onConfirm({ dest, name: name || undefined }); + }; + go.addEventListener("click", confirm); + nameInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + confirm(); + } + }); + + openModal((c) => { + close = c; + return { card, focusEl: nameInput, label: `Choose where ${fullName} goes`, onClose: () => {} }; + }); + + // Prefill the configured default folder; the sheet is usable before this + // lands (Choose… works regardless), it just can't confirm without a dest. + void host + .invoke("settings:get", undefined) + .then((v) => { + if (!dest) { + dest = v.cloneDir; + destValue.textContent = v.cloneDirDisplay; + destValue.title = v.cloneDir; + refresh(); + } + }) + .catch(() => { + destValue.textContent = "Choose a folder…"; + }); +} diff --git a/apps/desktop/src/renderer/dialogs.ts b/apps/desktop/src/renderer/dialogs.ts index 530938f..526cf8f 100644 --- a/apps/desktop/src/renderer/dialogs.ts +++ b/apps/desktop/src/renderer/dialogs.ts @@ -51,18 +51,32 @@ export function toast(message: string, kind: ToastKind = "info", timeoutMs?: num timer = window.setTimeout(dismiss, timeoutMs ?? (kind === "error" ? 7000 : 4000)); } -interface ModalSpec { +export interface ModalSpec { card: HTMLElement; focusEl: HTMLElement; /** Accessible name for the dialog (announced by screen readers). */ label?: string; /** Called on ANY close (button or dismiss) — resolve a default if needed. */ onClose: () => void; + /** Veto Esc/backdrop dismissal (return false to keep the modal up — e.g. a + * clone mid-flight). The explicit close() handed to build() always works. */ + canDismiss?: () => boolean; } -/** Focus-trapping modal scaffold shared by confirmDialog + promptInline. */ -function modal(build: (close: () => void) => ModalSpec): void { +/** Open-modal stack — Esc must dismiss only the TOPMOST modal, not every one + * listening on document (a prompt over the secrets manager used to take the + * manager down with it). */ +const modalStack: symbol[] = []; + +/** + * THE focus-trapping modal scaffold — overlay, Esc, Tab trap, backdrop + * dismissal, previous-focus restore. Every modal in the app builds on this + * (confirm/prompt/edit here; the section views' form modals via the export) so + * the dismissal contract can never drift between surfaces. + */ +export function openModal(build: (close: () => void) => ModalSpec): void { const prevFocus = document.activeElement as HTMLElement | null; + const token = Symbol("modal"); const overlay = mk("div", "modal-overlay"); overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); @@ -71,21 +85,31 @@ function modal(build: (close: () => void) => ModalSpec): void { const close = (): void => { if (closed) return; closed = true; + const i = modalStack.indexOf(token); + if (i >= 0) modalStack.splice(i, 1); spec.onClose(); overlay.remove(); document.removeEventListener("keydown", onKey, true); prevFocus?.focus?.(); }; + const dismiss = (): void => { + if (spec.canDismiss && !spec.canDismiss()) return; + close(); + }; const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { + if (modalStack[modalStack.length - 1] !== token) return; // a newer modal owns Esc + if (document.body.classList.contains("cmdk-open")) return; // the palette owns Esc e.preventDefault(); - close(); + dismiss(); return; } if (e.key !== "Tab") return; const f = Array.from( - spec.card.querySelectorAll("button, input, [tabindex]:not([tabindex='-1'])"), - ).filter((n) => !n.hasAttribute("disabled")); + spec.card.querySelectorAll( + "button, input, select, textarea, a[href], [tabindex]:not([tabindex='-1'])", + ), + ).filter((n) => !n.hasAttribute("disabled") && n.offsetParent !== null); if (!f.length) return; const first = f[0]; const last = f[f.length - 1]; @@ -101,19 +125,26 @@ function modal(build: (close: () => void) => ModalSpec): void { if (spec.label) overlay.setAttribute("aria-label", spec.label); overlay.appendChild(spec.card); document.body.appendChild(overlay); + modalStack.push(token); overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) close(); + if (e.target === overlay) dismiss(); }); document.addEventListener("keydown", onKey, true); setTimeout(() => spec.focusEl.focus(), 0); } +/** Internal alias — the pre-export name the local wrappers were written against. */ +const modal = openModal; + /** A styled confirmation dialog (replaces native confirm()); resolves true/false. */ export function confirmDialog(opts: { title: string; message: string; confirmLabel?: string; danger?: boolean; + /** Demand this exact text before enabling Confirm — for irreversible, + * disk-destroying actions where a mis-aimed click must not be enough. */ + requireTyped?: string; }): Promise { return new Promise((resolve) => { let settled = false; @@ -131,20 +162,47 @@ export function confirmDialog(opts: { okLabel.textContent = opts.confirmLabel ?? "Confirm"; ok.appendChild(okLabel); actions.append(cancel, ok); - card.append(h, body, actions); + card.append(h, body); + let typedInput: HTMLInputElement | undefined; + if (opts.requireTyped) { + const hint = mk("div", "modal-message confirm-typed-hint"); + hint.textContent = `Type ${opts.requireTyped} to confirm.`; + typedInput = document.createElement("input"); + typedInput.className = "modal-input confirm-typed-input"; + typedInput.placeholder = opts.requireTyped; + typedInput.spellcheck = false; + typedInput.autocapitalize = "off"; + typedInput.setAttribute("aria-label", `Type ${opts.requireTyped} to confirm`); + const sync = (): void => { + const match = typedInput!.value.trim() === opts.requireTyped; + if (match) ok.removeAttribute("disabled"); + else ok.setAttribute("disabled", "true"); + }; + typedInput.addEventListener("input", sync); + typedInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !ok.hasAttribute("disabled")) { + e.preventDefault(); + ok.click(); + } + }); + sync(); + card.append(hint, typedInput); + } + card.append(actions); cancel.addEventListener("click", () => { settled = true; resolve(false); close(); }); ok.addEventListener("click", () => { + if (ok.hasAttribute("disabled")) return; settled = true; resolve(true); close(); }); return { card, - focusEl: ok, + focusEl: typedInput ?? ok, label: opts.title, onClose: () => { if (!settled) resolve(false); diff --git a/apps/desktop/src/renderer/exploreRoutes.ts b/apps/desktop/src/renderer/exploreRoutes.ts new file mode 100644 index 0000000..d1ac7b8 --- /dev/null +++ b/apps/desktop/src/renderer/exploreRoutes.ts @@ -0,0 +1,77 @@ +// Explore's routing vocabulary — pure, DOM-free, node-tested. +// +// Explore states ride in `target.id` as micro-paths rather than new +// SectionTarget fields, so ⌘[ / Esc walk search → repo → folder → file with no +// change to the shared navigation contract: +// +// q// +// repo// +// repo///(tree|blob)// +// user/ org/ +// +// That makes these parsers load-bearing for navigation: a wrong parse doesn't +// throw, it silently strands someone on the wrong page. Hence the tests. + +export type ExploreTab = "repos" | "users" | "orgs" | "code"; + +/** A parsed `repo/…` target. */ +export interface RepoRoute { + fullName: string; + /** "" for the repo root. */ + path: string; + /** Undefined = the repo's default branch. */ + ref?: string; + kind: "tree" | "blob"; +} + +/** `q//` — the routed form of a search. */ +export function searchTargetId(tab: ExploreTab, query: string): string { + return `q/${tab}/${query}`; +} + +/** Parse a routed search target. Unknown shapes are ignored rather than + * throwing — a stale history entry must never break the view. */ +export function parseExploreTarget( + id: string | undefined, +): { tab: ExploreTab; query: string } | undefined { + if (!id) return undefined; + const m = /^q\/(repos|users|orgs|code)\/([\s\S]*)$/.exec(id); + if (!m) return undefined; + return { tab: m[1] as ExploreTab, query: m[2] }; +} + +/** `repo//[/tree|blob//]` → a route, or undefined. */ +export function parseRepoRoute(id: string | undefined): RepoRoute | undefined { + if (!id) return undefined; + const m = /^repo\/([^/]+)\/([^/]+)(?:\/(tree|blob)\/([^/]+)(?:\/([\s\S]*))?)?$/.exec(id); + if (!m) return undefined; + return { + fullName: `${m[1]}/${m[2]}`, + kind: (m[3] as "tree" | "blob") ?? "tree", + // "HEAD" is the sentinel a path-carrying route uses when no explicit ref + // was chosen — it must parse back to "the default branch", or walking into + // a file would silently pin the ref and relabel the switcher. + ref: m[4] && m[4] !== "HEAD" ? decodeURIComponent(m[4]) : undefined, + path: m[5] ?? "", + }; +} + +/** The inverse — build the routed id for a location in a repo. */ +export function repoRouteId(o: { + fullName: string; + path?: string; + ref?: string; + kind?: "tree" | "blob"; +}): string { + const base = `repo/${o.fullName}`; + if (!o.path && !o.ref) return base; + const ref = encodeURIComponent(o.ref ?? "HEAD"); + return `${base}/${o.kind ?? "tree"}/${ref}${o.path ? `/${o.path}` : ""}`; +} + +/** `user/` or `org/` → the login, or undefined. */ +export function parseAccountTarget(id: string | undefined): { login: string } | undefined { + if (!id) return undefined; + const m = /^(?:user|org)\/([^/]+)$/.exec(id); + return m ? { login: m[1] } : undefined; +} diff --git a/apps/desktop/src/renderer/facetModel.ts b/apps/desktop/src/renderer/facetModel.ts new file mode 100644 index 0000000..0942f9c --- /dev/null +++ b/apps/desktop/src/renderer/facetModel.ts @@ -0,0 +1,92 @@ +// The pure half of the facet system: what a facet IS, which items survive it, +// and which values are the server's problem rather than ours. +// +// DOM-free on purpose (like logModel.ts next to logView.ts) so the filtering +// rules unit-test under plain node. `views/common.ts` builds the buttons and +// menus on top of this and re-exports these names, so views import one place. + +/** One option in a facet menu. `value` is what lands in the state. */ +export interface FacetOption { + value: string; + label?: string; + /** Optional leading glyph (e.g. a state icon). */ + icon?: string; + /** A pre-built leading element — a label swatch, an avatar. Wins over `icon`, + * and is a FACTORY because a DOM node can only live in one menu at a time. */ + iconEl?: () => HTMLElement; +} + +/** + * One facet — a named dimension a list can be narrowed by. + * + * `options` is either fixed, harvested from the items on screen (so a Label + * facet offers exactly the labels present), or loaded asynchronously + * (workflows, which the rows don't name). + * + * `predicate` is what makes a facet CLIENT-side. Omit it and the facet is + * declared server-side: its value surfaces through `serverValues()` and the + * view passes it to the API instead of filtering on screen. That distinction + * is the whole design — a server facet that ALSO filtered locally would + * silently hide rows the server already excluded. + */ +export interface FacetSpec { + /** Stable key — also the state key and the API parameter name. */ + key: string; + /** Human label, shown on the button when nothing is selected. */ + label: string; + icon: string; + options?: FacetOption[]; + /** Derive options from the items currently loaded. */ + harvest?: (items: T[]) => FacetOption[]; + /** Load options on first open (cached for the bar's lifetime). */ + load?: () => Promise; + /** Client-side test. OMIT for a server-side facet. */ + predicate?: (item: T, value: string) => boolean; + /** Label for the "no filter" menu entry (default "Any